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 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
165pub struct CliApp {
171 registry: StoreRegistry,
172 config: Option<AppConfig>,
173}
174
175impl Default for CliApp {
176 fn default() -> Self {
180 Self {
181 registry: StoreRegistry::new(),
182 config: None,
183 }
184 }
185}
186
187impl CliApp {
188 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 pub fn register_backend(mut self, factory: Box<dyn StoreFactory>) -> Self {
237 self.registry.register(factory);
238 self
239 }
240
241 pub fn with_config(mut self, config: AppConfig) -> Self {
243 self.config = Some(config);
244 self
245 }
246
247 pub fn registry(&self) -> &StoreRegistry {
249 &self.registry
250 }
251
252 pub async fn run(self) -> anyhow::Result<()> {
255 self.run_with_args(std::env::args_os()).await
256 }
257
258 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 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 }
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}