1
use clap::Parser;
2
use cli_core::start_server;
3
use exitfailure::ExitFailure;
4
use log::LevelFilter;
5
use sqlx::types::Uuid;
6
use tui::{App, ConsoleEval, EditMode, LocalTransport, Transport, chart, run_loop};
7

            
8
#[derive(Parser, Debug)]
9
#[command(name = "nomisync-tui", about = "Nomisync interactive TUI")]
10
struct Cli {
11
    #[arg(short = 'u', long)]
12
    userid: Uuid,
13

            
14
    #[arg(short = 'd', long)]
15
    database: Option<String>,
16

            
17
    #[arg(long, default_value = "warn")]
18
    loglevel: LevelFilter,
19

            
20
    #[arg(long, value_parser = parse_edit_mode, default_value = "emacs")]
21
    edit_mode: EditMode,
22
}
23

            
24
3
fn parse_edit_mode(s: &str) -> Result<EditMode, String> {
25
3
    match s.to_ascii_lowercase().as_str() {
26
3
        "emacs" => Ok(EditMode::Emacs),
27
2
        "vim" => Ok(EditMode::Vim),
28
1
        _ => Err(format!("edit-mode must be `emacs` or `vim`, got `{s}`")),
29
    }
30
3
}
31

            
32
/// Combine the event loop's result with the transport cleanup's result
33
/// into a single outcome. The loop error (user-facing) wins over the
34
/// cleanup error (mostly cosmetic) when both fire; the cleanup error
35
/// is still logged via `on_cleanup_error` so we leave a trace.
36
///
37
/// Pulled out of `main` so it can be unit-tested — `#[tokio::main]` is
38
/// otherwise opaque to the test harness.
39
4
fn combine_outcomes<F>(
40
4
    loop_result: std::io::Result<()>,
41
4
    finish_result: std::io::Result<()>,
42
4
    on_cleanup_error: F,
43
4
) -> std::io::Result<()>
44
4
where
45
4
    F: FnOnce(&std::io::Error),
46
{
47
4
    match (loop_result, finish_result) {
48
1
        (Ok(()), Ok(())) => Ok(()),
49
1
        (Err(loop_err), Ok(())) => Err(loop_err),
50
1
        (Ok(()), Err(finish_err)) => Err(finish_err),
51
1
        (Err(loop_err), Err(finish_err)) => {
52
1
            on_cleanup_error(&finish_err);
53
1
            Err(loop_err)
54
        }
55
    }
56
4
}
57

            
58
#[tokio::main]
59
async fn main() -> Result<(), ExitFailure> {
60
    let cli = Cli::parse();
61

            
62
    env_logger::Builder::new()
63
        .filter_level(cli.loglevel)
64
        .target(env_logger::Target::Stderr)
65
        .init();
66

            
67
    start_server(cli.database, None).await?;
68

            
69
    let mut transport = LocalTransport::new()?;
70
    let mut app = App::new(cli.userid, cli.edit_mode);
71
    let backend_kind = chart::detect_from_env();
72
    app.set_status(format!("user={} chart={backend_kind:?}", app.user_id));
73

            
74
    // Attach a real console eval only when a database is reachable. With
75
    // no `DATABASE_URL` in the environment the console stays unattached
76
    // (a bare REPL with no DB) rather than failing the whole TUI.
77
    if std::env::var_os("DATABASE_URL").is_some() {
78
        let handle = tokio::runtime::Handle::current();
79
        match ConsoleEval::spawn(&handle, cli.userid) {
80
            Ok(eval) => app.attach_console(eval),
81
            Err(err) => log::warn!("console eval unavailable: {err}"),
82
        }
83
    }
84

            
85
    let loop_result = run_loop(&mut transport, &mut app);
86
    // Always attempt to restore the terminal, even on a loop error —
87
    // otherwise the user's shell inherits raw-mode + alternate-screen
88
    // state and becomes unusable.
89
    let finish_result = transport.finish();
90

            
91
    combine_outcomes(loop_result, finish_result, |cleanup_err| {
92
        eprintln!("Cleanup error: {cleanup_err:?}");
93
    })?;
94
    Ok(())
95
}
96

            
97
#[cfg(test)]
98
mod tests {
99
    use super::*;
100
    use std::cell::Cell;
101
    use std::io;
102

            
103
    #[test]
104
1
    fn parse_edit_mode_accepts_canonical_names() {
105
1
        assert_eq!(parse_edit_mode("emacs").unwrap(), EditMode::Emacs);
106
1
        assert_eq!(parse_edit_mode("VIM").unwrap(), EditMode::Vim);
107
1
    }
108

            
109
    #[test]
110
1
    fn parse_edit_mode_rejects_unknown() {
111
1
        assert!(parse_edit_mode("nano").is_err());
112
1
    }
113

            
114
    fn silent(_: &io::Error) {}
115

            
116
    #[test]
117
1
    fn combine_outcomes_all_ok() {
118
1
        assert!(combine_outcomes(Ok(()), Ok(()), silent).is_ok());
119
1
    }
120

            
121
    #[test]
122
1
    fn combine_outcomes_loop_err_wins_when_alone() {
123
1
        let err = combine_outcomes::<fn(&io::Error)>(Err(io::Error::other("loop")), Ok(()), silent)
124
1
            .unwrap_err();
125
1
        assert_eq!(err.to_string(), "loop");
126
1
    }
127

            
128
    #[test]
129
1
    fn combine_outcomes_cleanup_err_surfaces_when_alone() {
130
1
        let err =
131
1
            combine_outcomes::<fn(&io::Error)>(Ok(()), Err(io::Error::other("cleanup")), silent)
132
1
                .unwrap_err();
133
1
        assert_eq!(err.to_string(), "cleanup");
134
1
    }
135

            
136
    #[test]
137
1
    fn combine_outcomes_reports_cleanup_when_both_fail() {
138
1
        let saw_cleanup = Cell::new(false);
139
1
        let err = combine_outcomes(
140
1
            Err(io::Error::other("loop")),
141
1
            Err(io::Error::other("cleanup")),
142
1
            |cleanup_err| {
143
1
                saw_cleanup.set(true);
144
1
                assert_eq!(cleanup_err.to_string(), "cleanup");
145
1
            },
146
        )
147
1
        .unwrap_err();
148
1
        assert_eq!(err.to_string(), "loop", "loop error should be returned");
149
1
        assert!(saw_cleanup.get(), "cleanup callback must fire");
150
1
    }
151
}