Skip to main content

kanban_cli/
app.rs

1use crate::cli::{Cli, Commands};
2use crate::context::CliContext;
3use crate::handlers;
4use crate::output;
5use clap::{CommandFactory, FromArgMatches};
6use kanban_core::AppConfig;
7use kanban_domain::KanbanOperations;
8use kanban_persistence::{StoreFactory, StoreRegistry};
9use kanban_service::StoreManager;
10#[cfg(feature = "tui")]
11use kanban_tui::App;
12
13fn open_debug_log_file() -> Option<std::fs::File> {
14    std::env::var("KANBAN_DEBUG_LOG").ok().and_then(|log_path| {
15        std::fs::OpenOptions::new()
16            .create(true)
17            .append(true)
18            .open(&log_path)
19            .ok()
20    })
21}
22
23fn init_tracing_cli() {
24    use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
25
26    if let Some(log_file) = open_debug_log_file() {
27        tracing_subscriber::registry()
28            .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug")))
29            .with(
30                tracing_subscriber::fmt::layer()
31                    .with_writer(log_file)
32                    .with_ansi(false)
33                    .with_target(true)
34                    .with_thread_ids(true)
35                    .with_file(true)
36                    .with_line_number(true),
37            )
38            .try_init()
39            .ok();
40        return;
41    }
42    tracing_subscriber::registry()
43        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")))
44        .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
45        .try_init()
46        .ok();
47}
48
49#[cfg(feature = "tui")]
50fn init_tracing_tui(
51    error_log: std::sync::Arc<std::sync::Mutex<kanban_tui::error_log::ErrorLogState>>,
52) {
53    use kanban_tui::error_log::InMemoryLogLayer;
54    use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
55
56    let in_memory = InMemoryLogLayer::new(error_log);
57    if let Some(log_file) = open_debug_log_file() {
58        tracing_subscriber::registry()
59            .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug")))
60            .with(
61                tracing_subscriber::fmt::layer()
62                    .with_writer(log_file)
63                    .with_ansi(false)
64                    .with_target(true)
65                    .with_thread_ids(true)
66                    .with_file(true)
67                    .with_line_number(true),
68            )
69            .with(in_memory)
70            .try_init()
71            .ok();
72        return;
73    }
74    tracing_subscriber::registry()
75        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")))
76        .with(in_memory)
77        .try_init()
78        .ok();
79}
80
81fn parse_cli<I, T>(store_manager: &StoreManager, args: I) -> anyhow::Result<(Cli, clap::Command)>
82where
83    I: IntoIterator<Item = T>,
84    T: Into<std::ffi::OsString> + Clone,
85{
86    let backend_names: Vec<String> = store_manager
87        .backend_names()
88        .into_iter()
89        .map(str::to_owned)
90        .collect();
91    let mut cmd = Cli::command().mut_subcommand("migrate", |sub| {
92        sub.mut_arg("backend", |arg| {
93            arg.value_parser(clap::builder::PossibleValuesParser::new(
94                backend_names.clone(),
95            ))
96        })
97    });
98    // For -V/--version and --help, clap returns Err with a kind that
99    // signals "print this and exit cleanly". `e.exit()` dispatches
100    // per-kind internally — DisplayHelp / DisplayVersion go to stdout
101    // with exit code 0; real argument errors go to stderr with exit
102    // code 2. No `match e.kind()` needed here. Without it the error
103    // propagates through main's generic eprintln!("Error: {e}") path,
104    // sending the version / help text to stderr with exit 1 and a
105    // doubled trailing newline.
106    let matches = cmd
107        .try_get_matches_from_mut(args)
108        .unwrap_or_else(|e| e.exit());
109    let cli = Cli::from_arg_matches(&matches)?;
110    Ok((cli, cmd))
111}
112
113#[derive(serde::Serialize)]
114struct InitFileResult<'a> {
115    file: &'a str,
116}
117
118async fn create_empty_storage_file(
119    store_manager: &StoreManager,
120    file: &str,
121    config: &AppConfig,
122) -> anyhow::Result<()> {
123    use kanban_domain::Snapshot;
124    use kanban_persistence::{snapshot_to_json_bytes, PersistenceMetadata, StoreSnapshot};
125    let store = store_manager.make_store_with_config(Some(file), config)?;
126    let data = snapshot_to_json_bytes(&Snapshot::new()).map_err(|e| anyhow::anyhow!("{e}"))?;
127    let metadata = PersistenceMetadata::new(uuid::Uuid::new_v4());
128    store
129        .save(StoreSnapshot { data, metadata })
130        .await
131        .map_err(|e| anyhow::anyhow!("{e}"))?;
132    Ok(())
133}
134
135async fn dispatch_subcommand(ctx: &mut CliContext, cmd: Commands) -> anyhow::Result<()> {
136    match cmd {
137        Commands::Board(board_cmd) => {
138            handlers::board::handle(ctx, board_cmd.action).await?;
139        }
140        Commands::Column(column_cmd) => {
141            handlers::column::handle(ctx, column_cmd.action).await?;
142        }
143        Commands::Card(card_cmd) => {
144            handlers::card::handle(ctx, card_cmd.action).await?;
145        }
146        Commands::Relation(relation_cmd) => {
147            handlers::relation::handle(ctx, relation_cmd.action).await?;
148        }
149        Commands::Sprint(sprint_cmd) => {
150            handlers::sprint::handle(ctx, sprint_cmd.action).await?;
151        }
152        Commands::Export(args) => {
153            handlers::export::handle_export(ctx, args).await?;
154        }
155        Commands::Import(args) => {
156            handlers::export::handle_import(ctx, args).await?;
157        }
158        Commands::Completions { .. } | Commands::Migrate(_) | Commands::Init { .. } => {
159            unreachable!()
160        }
161    }
162    Ok(())
163}
164
165/// Builder entry point for the Kanban CLI.
166///
167/// A third-party backend crate constructs a `CliApp`, registers its own
168/// `StoreFactory`, and calls [`CliApp::run`] from its own `main` — owning
169/// the binary while reusing every CLI command here.
170pub struct CliApp {
171    registry: StoreRegistry,
172    config: Option<AppConfig>,
173}
174
175impl Default for CliApp {
176    /// Returns an empty `CliApp` with **no** registered backends. Callers
177    /// must register at least one via [`CliApp::register_backend`] before
178    /// `run` can produce a store.
179    fn default() -> Self {
180        Self {
181            registry: StoreRegistry::new(),
182            config: None,
183        }
184    }
185}
186
187impl CliApp {
188    /// Returns a `CliApp` pre-configured with all backends compiled in.
189    /// SQLite is registered first so content-sniffing prefers it; JSON is
190    /// registered as the catch-all fallback. When no backend features are
191    /// active the registry is empty (same as [`Default`]).
192    pub fn with_defaults() -> Self {
193        #[cfg(any(feature = "json", feature = "sqlite"))]
194        let registry = kanban_service::default_registry();
195        #[cfg(not(any(feature = "json", feature = "sqlite")))]
196        let registry = kanban_persistence::StoreRegistry::new();
197        Self {
198            registry,
199            config: None,
200        }
201    }
202
203    /// Registers an additional backend factory. Order matters for content
204    /// sniffing — factories registered earlier win when multiple match.
205    ///
206    /// # Example — third-party binary with a custom backend
207    ///
208    /// A crate that owns its own `main` can reuse every CLI command while
209    /// injecting a proprietary storage backend:
210    ///
211    /// ```no_run
212    /// use kanban_cli::CliApp;
213    /// use kanban_persistence::{PersistenceError, PersistenceStore, StoreFactory};
214    /// use std::sync::Arc;
215    ///
216    /// // A backend factory provided by a third-party crate.
217    /// struct MyBackendFactory;
218    /// impl StoreFactory for MyBackendFactory {
219    ///     fn name(&self) -> &str { "my-backend" }
220    ///     fn create(
221    ///         &self,
222    ///         locator: &str,
223    ///     ) -> Result<Arc<dyn PersistenceStore + Send + Sync>, PersistenceError> {
224    ///         unimplemented!()
225    ///     }
226    /// }
227    ///
228    /// #[tokio::main]
229    /// async fn main() -> anyhow::Result<()> {
230    ///     CliApp::with_defaults()
231    ///         .register_backend(Box::new(MyBackendFactory))
232    ///         .run()
233    ///         .await
234    /// }
235    /// ```
236    pub fn register_backend(mut self, factory: Box<dyn StoreFactory>) -> Self {
237        self.registry.register(factory);
238        self
239    }
240
241    /// Overrides the `AppConfig` that `run` would otherwise load from disk.
242    pub fn with_config(mut self, config: AppConfig) -> Self {
243        self.config = Some(config);
244        self
245    }
246
247    /// Exposes the underlying registry for inspection and tests.
248    pub fn registry(&self) -> &StoreRegistry {
249        &self.registry
250    }
251
252    /// Executes the CLI: parses args, loads config, and dispatches to the
253    /// requested command (or launches the TUI if no subcommand was given).
254    pub async fn run(self) -> anyhow::Result<()> {
255        self.run_with_args(std::env::args_os()).await
256    }
257
258    /// Like [`run`], but accepts an explicit argument list instead of reading
259    /// from `std::env::args_os()`. Useful for testing without spawning a
260    /// subprocess.
261    pub async fn run_with_args<I, T>(self, args: I) -> anyhow::Result<()>
262    where
263        I: IntoIterator<Item = T>,
264        T: Into<std::ffi::OsString> + Clone,
265    {
266        let store_manager = StoreManager::new(self.registry);
267        let (Cli { command, file }, mut cmd) = parse_cli(&store_manager, args)?;
268
269        if let Some(Commands::Completions { shell }) = command {
270            clap_complete::generate(shell, &mut cmd, "kanban", &mut std::io::stdout());
271            return Ok(());
272        }
273
274        if !store_manager.has_backends() {
275            anyhow::bail!(
276                "No storage backends registered. \
277                 Use CliApp::with_defaults() or call register_backend() before run()."
278            );
279        }
280
281        let config = self.config.unwrap_or_else(kanban_service::config::load);
282        let validated_file: Option<String> = match file {
283            Some(ref p) => Some(
284                kanban_service::validate_path(std::path::Path::new(p))?
285                    .to_string_lossy()
286                    .to_string(),
287            ),
288            None => None,
289        };
290        let effective_file = validated_file
291            .clone()
292            .unwrap_or_else(|| kanban_service::config::resolve_storage_location(&config));
293
294        let needs_data_file = !matches!(
295            &command,
296            None | Some(Commands::Completions { .. })
297                | Some(Commands::Migrate(_))
298                | Some(Commands::Init { .. })
299        );
300        if needs_data_file && validated_file.is_none() && config.storage_location.is_none() {
301            anyhow::bail!(
302                "\
303No data file specified.
304
305Provide the file path in one of these ways:
306  kanban <path>           (first positional argument)
307  KANBAN_FILE=<path>      (environment variable)
308  storage_location = ...  (config file setting)"
309            );
310        }
311
312        match command {
313            None => {
314                // KANBAN_FILE env var resolves into validated_file via clap's env attribute.
315                let has_explicit_file =
316                    validated_file.is_some() || config.storage_location.is_some();
317                if has_explicit_file && !std::path::Path::new(&effective_file).exists() {
318                    create_empty_storage_file(&store_manager, &effective_file, &config).await?;
319                }
320                use std::io::IsTerminal;
321                if std::io::stdin().is_terminal() {
322                    #[cfg(feature = "tui")]
323                    {
324                        let error_log = std::sync::Arc::new(std::sync::Mutex::new(
325                            kanban_tui::error_log::ErrorLogState::default(),
326                        ));
327                        init_tracing_tui(std::sync::Arc::clone(&error_log));
328                        let (mut app, save_rx) =
329                            App::new_with_store(store_manager, validated_file).await?;
330                        app.set_error_log(error_log);
331                        app.run(save_rx).await?;
332                    }
333                    #[cfg(not(feature = "tui"))]
334                    anyhow::bail!(
335                        "TUI not available in this build. Run `kanban --help` for available subcommands."
336                    );
337                }
338                // Non-TTY: file created above if needed, exit cleanly.
339            }
340            Some(Commands::Completions { .. }) => unreachable!(),
341            Some(Commands::Migrate(args)) => {
342                init_tracing_cli();
343                handlers::migrate::handle(&store_manager, args).await?;
344            }
345            Some(Commands::Init { board }) => {
346                init_tracing_cli();
347                match board {
348                    Some(name) => {
349                        let mut ctx =
350                            CliContext::load(&store_manager, &effective_file, config).await?;
351                        let created = ctx.create_board(name, None)?;
352                        ctx.save().await?;
353                        output::output_success(&created);
354                    }
355                    None => {
356                        if !std::path::Path::new(&effective_file).exists() {
357                            create_empty_storage_file(&store_manager, &effective_file, &config)
358                                .await?;
359                        }
360                        output::output_success(InitFileResult {
361                            file: &effective_file,
362                        });
363                    }
364                }
365            }
366            Some(cmd) => {
367                init_tracing_cli();
368                if !std::path::Path::new(&effective_file).exists() {
369                    return crate::output::output_error(&format!(
370                        "Board file not found: '{}'",
371                        effective_file
372                    ));
373                }
374                let mut ctx = CliContext::load(&store_manager, &effective_file, config).await?;
375                dispatch_subcommand(&mut ctx, cmd).await?;
376            }
377        }
378
379        Ok(())
380    }
381}