1use std::env;
2use std::path::PathBuf;
3
4use clap::{Args, Parser, Subcommand, ValueEnum};
5
6use crate::errors::AppError;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
9pub enum OutputFormat {
10 Text,
11 Json,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum OutputMode {
16 Text,
17 Json,
18}
19
20impl OutputMode {
21 pub fn is_json(self) -> bool {
22 matches!(self, Self::Json)
23 }
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
27pub enum ItemState {
28 All,
29 Pending,
30 Enriched,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
34pub enum SearchField {
35 Raw,
36 Derived,
37 Tags,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
41pub enum SearchMatch {
42 Fts,
43 Prefix,
44 Contains,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
48pub enum ReportPeriod {
49 Week,
50 Month,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
54pub enum FetchState {
55 Pending,
56}
57
58#[derive(Debug, Parser)]
59#[command(
60 name = "memo-cli",
61 version,
62 about = "Capture-first memo CLI with agent enrichment"
63)]
64pub struct Cli {
65 #[arg(long, global = true, value_name = "path", default_value_os_t = default_db_path())]
67 pub db: PathBuf,
68
69 #[arg(long, global = true)]
71 pub json: bool,
72
73 #[arg(long, global = true, value_enum)]
75 pub format: Option<OutputFormat>,
76
77 #[command(subcommand)]
78 pub command: MemoCommand,
79}
80
81#[derive(Debug, Subcommand)]
82pub enum MemoCommand {
83 Add(AddArgs),
85 Update(UpdateArgs),
87 Delete(DeleteArgs),
89 List(ListArgs),
91 Search(SearchArgs),
93 Report(ReportArgs),
95 Fetch(FetchArgs),
97 Apply(ApplyArgs),
99 Completion(CompletionArgs),
101}
102
103#[derive(Debug, Args)]
104pub struct CompletionArgs {
105 #[arg(value_enum)]
106 pub shell: crate::completion::CompletionShell,
107}
108
109#[derive(Debug, clap::Args)]
110pub struct AddArgs {
111 pub text: String,
113
114 #[arg(long, default_value = "cli")]
116 pub source: String,
117
118 #[arg(long)]
120 pub at: Option<String>,
121}
122
123#[derive(Debug, clap::Args)]
124pub struct UpdateArgs {
125 pub item_id: String,
127
128 pub text: String,
130}
131
132#[derive(Debug, clap::Args)]
133pub struct DeleteArgs {
134 pub item_id: String,
136
137 #[arg(long)]
139 pub hard: bool,
140}
141
142#[derive(Debug, clap::Args)]
143pub struct ListArgs {
144 #[arg(long, default_value_t = 20)]
146 pub limit: usize,
147
148 #[arg(long, default_value_t = 0)]
150 pub offset: usize,
151
152 #[arg(long, value_enum, default_value_t = ItemState::All)]
154 pub state: ItemState,
155}
156
157#[derive(Debug, clap::Args)]
158pub struct SearchArgs {
159 pub query: String,
161
162 #[arg(long, default_value_t = 20)]
164 pub limit: usize,
165
166 #[arg(long, value_enum, default_value_t = ItemState::All)]
168 pub state: ItemState,
169
170 #[arg(
172 long = "field",
173 value_enum,
174 value_delimiter = ',',
175 default_values_t = [SearchField::Raw, SearchField::Derived, SearchField::Tags]
176 )]
177 pub fields: Vec<SearchField>,
178
179 #[arg(long = "match", value_enum, default_value_t = SearchMatch::Fts)]
181 pub match_mode: SearchMatch,
182}
183
184#[derive(Debug, clap::Args)]
185pub struct ReportArgs {
186 pub period: ReportPeriod,
188
189 #[arg(long)]
191 pub tz: Option<String>,
192
193 #[arg(long)]
195 pub from: Option<String>,
196
197 #[arg(long)]
199 pub to: Option<String>,
200}
201
202#[derive(Debug, clap::Args)]
203pub struct FetchArgs {
204 #[arg(long, default_value_t = 50)]
206 pub limit: usize,
207
208 #[arg(long)]
210 pub cursor: Option<String>,
211
212 #[arg(long, value_enum, default_value_t = FetchState::Pending)]
214 pub state: FetchState,
215}
216
217#[derive(Debug, clap::Args)]
218pub struct ApplyArgs {
219 #[arg(long)]
221 pub input: Option<PathBuf>,
222
223 #[arg(long)]
225 pub stdin: bool,
226
227 #[arg(long)]
229 pub dry_run: bool,
230}
231
232impl Cli {
233 pub fn resolve_output_mode(&self) -> Result<OutputMode, AppError> {
234 if self.json && matches!(self.format, Some(OutputFormat::Text)) {
235 return Err(AppError::usage(
236 "invalid output mode: --json cannot be combined with --format text",
237 ));
238 }
239
240 if self.json || matches!(self.format, Some(OutputFormat::Json)) {
241 return Ok(OutputMode::Json);
242 }
243
244 Ok(OutputMode::Text)
245 }
246
247 pub fn command_id(&self) -> &'static str {
248 match self.command {
249 MemoCommand::Add(_) => "memo-cli add",
250 MemoCommand::Update(_) => "memo-cli update",
251 MemoCommand::Delete(_) => "memo-cli delete",
252 MemoCommand::List(_) => "memo-cli list",
253 MemoCommand::Search(_) => "memo-cli search",
254 MemoCommand::Report(_) => "memo-cli report",
255 MemoCommand::Fetch(_) => "memo-cli fetch",
256 MemoCommand::Apply(_) => "memo-cli apply",
257 MemoCommand::Completion(_) => "memo-cli completion",
258 }
259 }
260
261 pub fn schema_version(&self) -> &'static str {
262 match self.command {
263 MemoCommand::Add(_) => "memo-cli.add.v1",
264 MemoCommand::Update(_) => "memo-cli.update.v1",
265 MemoCommand::Delete(_) => "memo-cli.delete.v1",
266 MemoCommand::List(_) => "memo-cli.list.v1",
267 MemoCommand::Search(_) => "memo-cli.search.v1",
268 MemoCommand::Report(_) => "memo-cli.report.v1",
269 MemoCommand::Fetch(_) => "memo-cli.fetch.v1",
270 MemoCommand::Apply(_) => "memo-cli.apply.v1",
271 MemoCommand::Completion(_) => "memo-cli.completion.v1",
272 }
273 }
274}
275
276fn default_db_path() -> PathBuf {
277 if let Some(data_home) = env::var_os("XDG_DATA_HOME") {
278 return PathBuf::from(data_home).join("nils-cli").join("memo.db");
279 }
280
281 if let Some(home) = env::var_os("HOME") {
282 return PathBuf::from(home)
283 .join(".local")
284 .join("share")
285 .join("nils-cli")
286 .join("memo.db");
287 }
288
289 PathBuf::from("memo.db")
290}
291
292#[cfg(test)]
293pub(crate) mod tests {
294 use clap::{CommandFactory, Parser};
295
296 use super::{Cli, MemoCommand, OutputMode, SearchField, SearchMatch};
297
298 #[test]
299 fn output_mode_defaults_to_text() {
300 let cli = Cli::parse_from(["memo-cli", "list"]);
301 let mode = cli.resolve_output_mode().expect("mode should resolve");
302 assert_eq!(mode, OutputMode::Text);
303 }
304
305 #[test]
306 fn output_mode_json_flag_wins() {
307 let cli = Cli::parse_from(["memo-cli", "--json", "list"]);
308 let mode = cli.resolve_output_mode().expect("mode should resolve");
309 assert_eq!(mode, OutputMode::Json);
310 }
311
312 #[test]
313 fn output_mode_format_json_is_supported() {
314 let cli = Cli::parse_from(["memo-cli", "--format", "json", "list"]);
315 let mode = cli.resolve_output_mode().expect("mode should resolve");
316 assert_eq!(mode, OutputMode::Json);
317 }
318
319 #[test]
320 fn output_mode_rejects_conflict() {
321 let cli = Cli::parse_from(["memo-cli", "--json", "--format", "text", "list"]);
322 let err = cli.resolve_output_mode().expect_err("conflict should fail");
323 assert_eq!(err.exit_code(), 64);
324 }
325
326 #[test]
327 fn parser_exposes_expected_subcommands() {
328 let mut cmd = Cli::command();
329 let subcommands = cmd
330 .get_subcommands_mut()
331 .map(|sub| sub.get_name().to_string())
332 .collect::<Vec<_>>();
333 assert!(subcommands.contains(&"add".to_string()));
334 assert!(subcommands.contains(&"update".to_string()));
335 assert!(subcommands.contains(&"delete".to_string()));
336 assert!(subcommands.contains(&"list".to_string()));
337 assert!(subcommands.contains(&"search".to_string()));
338 assert!(subcommands.contains(&"report".to_string()));
339 assert!(subcommands.contains(&"fetch".to_string()));
340 assert!(subcommands.contains(&"apply".to_string()));
341 assert!(subcommands.contains(&"completion".to_string()));
342 }
343
344 #[test]
345 fn search_fields_supports_comma_separated_values() {
346 let cli = Cli::parse_from(["memo-cli", "search", "ssd", "--field", "raw,tags"]);
347 let MemoCommand::Search(args) = cli.command else {
348 panic!("expected search command");
349 };
350
351 assert_eq!(args.fields, vec![SearchField::Raw, SearchField::Tags]);
352 }
353
354 #[test]
355 fn search_fields_default_to_all_fields() {
356 let cli = Cli::parse_from(["memo-cli", "search", "ssd"]);
357 let MemoCommand::Search(args) = cli.command else {
358 panic!("expected search command");
359 };
360
361 assert_eq!(
362 args.fields,
363 vec![SearchField::Raw, SearchField::Derived, SearchField::Tags]
364 );
365 }
366
367 #[test]
368 fn search_match_mode_defaults_to_fts() {
369 let cli = Cli::parse_from(["memo-cli", "search", "ssd"]);
370 let MemoCommand::Search(args) = cli.command else {
371 panic!("expected search command");
372 };
373
374 assert_eq!(args.match_mode, SearchMatch::Fts);
375 }
376
377 #[test]
378 fn search_match_mode_accepts_explicit_value() {
379 let cli = Cli::parse_from(["memo-cli", "search", "ssd", "--match", "contains"]);
380 let MemoCommand::Search(args) = cli.command else {
381 panic!("expected search command");
382 };
383
384 assert_eq!(args.match_mode, SearchMatch::Contains);
385 }
386}