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
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
136pub struct CliApp {
142 registry: StoreRegistry,
143 config: Option<AppConfig>,
144}
145
146impl Default for CliApp {
147 fn default() -> Self {
151 Self {
152 registry: StoreRegistry::new(),
153 config: None,
154 }
155 }
156}
157
158impl CliApp {
159 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 pub fn register_backend(mut self, factory: Box<dyn StoreFactory>) -> Self {
208 self.registry.register(factory);
209 self
210 }
211
212 pub fn with_config(mut self, config: AppConfig) -> Self {
214 self.config = Some(config);
215 self
216 }
217
218 pub fn registry(&self) -> &StoreRegistry {
220 &self.registry
221 }
222
223 pub async fn run(self) -> anyhow::Result<()> {
226 self.run_with_args(std::env::args_os()).await
227 }
228
229 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 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 }
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}