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, 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
    let loop_result = run_loop(&mut transport, &mut app);
75
    // Always attempt to restore the terminal, even on a loop error —
76
    // otherwise the user's shell inherits raw-mode + alternate-screen
77
    // state and becomes unusable.
78
    let finish_result = transport.finish();
79

            
80
    combine_outcomes(loop_result, finish_result, |cleanup_err| {
81
        eprintln!("Cleanup error: {cleanup_err:?}");
82
    })?;
83
    Ok(())
84
}
85

            
86
#[cfg(test)]
87
mod tests {
88
    use super::*;
89
    use std::cell::Cell;
90
    use std::io;
91

            
92
    #[test]
93
1
    fn parse_edit_mode_accepts_canonical_names() {
94
1
        assert_eq!(parse_edit_mode("emacs").unwrap(), EditMode::Emacs);
95
1
        assert_eq!(parse_edit_mode("VIM").unwrap(), EditMode::Vim);
96
1
    }
97

            
98
    #[test]
99
1
    fn parse_edit_mode_rejects_unknown() {
100
1
        assert!(parse_edit_mode("nano").is_err());
101
1
    }
102

            
103
    fn silent(_: &io::Error) {}
104

            
105
    #[test]
106
1
    fn combine_outcomes_all_ok() {
107
1
        assert!(combine_outcomes(Ok(()), Ok(()), silent).is_ok());
108
1
    }
109

            
110
    #[test]
111
1
    fn combine_outcomes_loop_err_wins_when_alone() {
112
1
        let err = combine_outcomes::<fn(&io::Error)>(Err(io::Error::other("loop")), Ok(()), silent)
113
1
            .unwrap_err();
114
1
        assert_eq!(err.to_string(), "loop");
115
1
    }
116

            
117
    #[test]
118
1
    fn combine_outcomes_cleanup_err_surfaces_when_alone() {
119
1
        let err =
120
1
            combine_outcomes::<fn(&io::Error)>(Ok(()), Err(io::Error::other("cleanup")), silent)
121
1
                .unwrap_err();
122
1
        assert_eq!(err.to_string(), "cleanup");
123
1
    }
124

            
125
    #[test]
126
1
    fn combine_outcomes_reports_cleanup_when_both_fail() {
127
1
        let saw_cleanup = Cell::new(false);
128
1
        let err = combine_outcomes(
129
1
            Err(io::Error::other("loop")),
130
1
            Err(io::Error::other("cleanup")),
131
1
            |cleanup_err| {
132
1
                saw_cleanup.set(true);
133
1
                assert_eq!(cleanup_err.to_string(), "cleanup");
134
1
            },
135
        )
136
1
        .unwrap_err();
137
1
        assert_eq!(err.to_string(), "loop", "loop error should be returned");
138
1
        assert!(saw_cleanup.get(), "cleanup callback must fire");
139
1
    }
140
}