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    let matches = cmd.try_get_matches_from_mut(args)?;
97    let cli = Cli::from_arg_matches(&matches)?;
98    Ok((cli, cmd))
99}
100
101async fn dispatch_subcommand(ctx: &mut CliContext, cmd: Commands) -> anyhow::Result<()> {
102    match cmd {
103        Commands::Board(board_cmd) => {
104            handlers::board::handle(ctx, board_cmd.action).await?;
105        }
106        Commands::Column(column_cmd) => {
107            handlers::column::handle(ctx, column_cmd.action).await?;
108        }
109        Commands::Card(card_cmd) => {
110            handlers::card::handle(ctx, card_cmd.action).await?;
111        }
112        Commands::Sprint(sprint_cmd) => {
113            handlers::sprint::handle(ctx, sprint_cmd.action).await?;
114        }
115        Commands::Export(args) => {
116            handlers::export::handle_export(ctx, args).await?;
117        }
118        Commands::Import(args) => {
119            handlers::export::handle_import(ctx, args).await?;
120        }
121        Commands::Completions { .. } | Commands::Migrate(_) => unreachable!(),
122    }
123    Ok(())
124}
125
126/// Builder entry point for the Kanban CLI.
127///
128/// A third-party backend crate constructs a `CliApp`, registers its own
129/// `StoreFactory`, and calls [`CliApp::run`] from its own `main` — owning
130/// the binary while reusing every CLI command here.
131pub struct CliApp {
132    registry: StoreRegistry,
133    config: Option<AppConfig>,
134}
135
136impl Default for CliApp {
137    /// Returns an empty `CliApp` with **no** registered backends. Callers
138    /// must register at least one via [`CliApp::register_backend`] before
139    /// `run` can produce a store.
140    fn default() -> Self {
141        Self {
142            registry: StoreRegistry::new(),
143            config: None,
144        }
145    }
146}
147
148impl CliApp {
149    /// Returns a `CliApp` pre-configured with all backends compiled in.
150    /// SQLite is registered first so content-sniffing prefers it; JSON is
151    /// registered as the catch-all fallback. When no backend features are
152    /// active the registry is empty (same as [`Default`]).
153    pub fn with_defaults() -> Self {
154        #[cfg(any(feature = "json", feature = "sqlite"))]
155        let registry = kanban_service::default_registry();
156        #[cfg(not(any(feature = "json", feature = "sqlite")))]
157        let registry = kanban_persistence::StoreRegistry::new();
158        Self {
159            registry,
160            config: None,
161        }
162    }
163
164    /// Registers an additional backend factory. Order matters for content
165    /// sniffing — factories registered earlier win when multiple match.
166    ///
167    /// # Example — third-party binary with a custom backend
168    ///
169    /// A crate that owns its own `main` can reuse every CLI command while
170    /// injecting a proprietary storage backend:
171    ///
172    /// ```no_run
173    /// use kanban_cli::CliApp;
174    /// use kanban_persistence::{PersistenceError, PersistenceStore, StoreFactory};
175    /// use std::sync::Arc;
176    ///
177    /// // A backend factory provided by a third-party crate.
178    /// struct MyBackendFactory;
179    /// impl StoreFactory for MyBackendFactory {
180    ///     fn name(&self) -> &str { "my-backend" }
181    ///     fn create(
182    ///         &self,
183    ///         locator: &str,
184    ///     ) -> Result<Arc<dyn PersistenceStore + Send + Sync>, PersistenceError> {
185    ///         unimplemented!()
186    ///     }
187    /// }
188    ///
189    /// #[tokio::main]
190    /// async fn main() -> anyhow::Result<()> {
191    ///     CliApp::with_defaults()
192    ///         .register_backend(Box::new(MyBackendFactory))
193    ///         .run()
194    ///         .await
195    /// }
196    /// ```
197    pub fn register_backend(mut self, factory: Box<dyn StoreFactory>) -> Self {
198        self.registry.register(factory);
199        self
200    }
201
202    /// Overrides the `AppConfig` that `run` would otherwise load from disk.
203    pub fn with_config(mut self, config: AppConfig) -> Self {
204        self.config = Some(config);
205        self
206    }
207
208    /// Exposes the underlying registry for inspection and tests.
209    pub fn registry(&self) -> &StoreRegistry {
210        &self.registry
211    }
212
213    /// Executes the CLI: parses args, loads config, and dispatches to the
214    /// requested command (or launches the TUI if no subcommand was given).
215    pub async fn run(self) -> anyhow::Result<()> {
216        self.run_with_args(std::env::args_os()).await
217    }
218
219    /// Like [`run`], but accepts an explicit argument list instead of reading
220    /// from `std::env::args_os()`. Useful for testing without spawning a
221    /// subprocess.
222    pub async fn run_with_args<I, T>(self, args: I) -> anyhow::Result<()>
223    where
224        I: IntoIterator<Item = T>,
225        T: Into<std::ffi::OsString> + Clone,
226    {
227        let store_manager = StoreManager::new(self.registry);
228        let (Cli { command, file }, mut cmd) = parse_cli(&store_manager, args)?;
229
230        if let Some(Commands::Completions { shell }) = command {
231            clap_complete::generate(shell, &mut cmd, "kanban", &mut std::io::stdout());
232            return Ok(());
233        }
234
235        if !store_manager.has_backends() {
236            anyhow::bail!(
237                "No storage backends registered. \
238                 Use CliApp::with_defaults() or call register_backend() before run()."
239            );
240        }
241
242        let config = self.config.unwrap_or_else(kanban_service::config::load);
243        let validated_file: Option<String> = match file {
244            Some(ref p) => Some(
245                kanban_service::validate_path(std::path::Path::new(p))?
246                    .to_string_lossy()
247                    .to_string(),
248            ),
249            None => None,
250        };
251        let effective_file = validated_file
252            .clone()
253            .unwrap_or_else(|| kanban_service::config::resolve_storage_location(&config));
254
255        match command {
256            None => {
257                #[cfg(feature = "tui")]
258                {
259                    let error_log = std::sync::Arc::new(std::sync::Mutex::new(
260                        kanban_tui::error_log::ErrorLogState::default(),
261                    ));
262                    init_tracing_tui(std::sync::Arc::clone(&error_log));
263
264                    let (mut app, save_rx) =
265                        App::new_with_store(store_manager, validated_file).await?;
266                    app.set_error_log(error_log);
267                    app.run(save_rx).await?;
268                }
269                #[cfg(not(feature = "tui"))]
270                anyhow::bail!(
271                    "TUI not available in this build. Run `kanban --help` for available subcommands."
272                );
273            }
274            Some(Commands::Completions { .. }) => unreachable!(),
275            Some(Commands::Migrate(args)) => {
276                init_tracing_cli();
277                handlers::migrate::handle(&store_manager, args).await?;
278            }
279            Some(cmd) => {
280                init_tracing_cli();
281                let mut ctx = CliContext::load(&store_manager, &effective_file, config).await?;
282                dispatch_subcommand(&mut ctx, cmd).await?;
283            }
284        }
285
286        Ok(())
287    }
288}