Skip to main content

kanban_cli/
app.rs

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