1use std::env;
2use std::path::PathBuf;
3
4use clap::{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 ReportPeriod {
35 Week,
36 Month,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
40pub enum FetchState {
41 Pending,
42}
43
44#[derive(Debug, Parser)]
45#[command(
46 name = "memo-cli",
47 version,
48 about = "Capture-first memo CLI with agent enrichment"
49)]
50pub struct Cli {
51 #[arg(long, global = true, value_name = "path", default_value_os_t = default_db_path())]
53 pub db: PathBuf,
54
55 #[arg(long, global = true)]
57 pub json: bool,
58
59 #[arg(long, global = true, value_enum)]
61 pub format: Option<OutputFormat>,
62
63 #[command(subcommand)]
64 pub command: MemoCommand,
65}
66
67#[derive(Debug, Subcommand)]
68pub enum MemoCommand {
69 Add(AddArgs),
71 Update(UpdateArgs),
73 Delete(DeleteArgs),
75 List(ListArgs),
77 Search(SearchArgs),
79 Report(ReportArgs),
81 Fetch(FetchArgs),
83 Apply(ApplyArgs),
85}
86
87#[derive(Debug, clap::Args)]
88pub struct AddArgs {
89 pub text: String,
91
92 #[arg(long, default_value = "cli")]
94 pub source: String,
95
96 #[arg(long)]
98 pub at: Option<String>,
99}
100
101#[derive(Debug, clap::Args)]
102pub struct UpdateArgs {
103 pub item_id: String,
105
106 pub text: String,
108}
109
110#[derive(Debug, clap::Args)]
111pub struct DeleteArgs {
112 pub item_id: String,
114
115 #[arg(long)]
117 pub hard: bool,
118}
119
120#[derive(Debug, clap::Args)]
121pub struct ListArgs {
122 #[arg(long, default_value_t = 20)]
124 pub limit: usize,
125
126 #[arg(long, default_value_t = 0)]
128 pub offset: usize,
129
130 #[arg(long, value_enum, default_value_t = ItemState::All)]
132 pub state: ItemState,
133}
134
135#[derive(Debug, clap::Args)]
136pub struct SearchArgs {
137 pub query: String,
139
140 #[arg(long, default_value_t = 20)]
142 pub limit: usize,
143
144 #[arg(long, value_enum, default_value_t = ItemState::All)]
146 pub state: ItemState,
147}
148
149#[derive(Debug, clap::Args)]
150pub struct ReportArgs {
151 pub period: ReportPeriod,
153
154 #[arg(long)]
156 pub tz: Option<String>,
157
158 #[arg(long)]
160 pub from: Option<String>,
161
162 #[arg(long)]
164 pub to: Option<String>,
165}
166
167#[derive(Debug, clap::Args)]
168pub struct FetchArgs {
169 #[arg(long, default_value_t = 50)]
171 pub limit: usize,
172
173 #[arg(long)]
175 pub cursor: Option<String>,
176
177 #[arg(long, value_enum, default_value_t = FetchState::Pending)]
179 pub state: FetchState,
180}
181
182#[derive(Debug, clap::Args)]
183pub struct ApplyArgs {
184 #[arg(long)]
186 pub input: Option<PathBuf>,
187
188 #[arg(long)]
190 pub stdin: bool,
191
192 #[arg(long)]
194 pub dry_run: bool,
195}
196
197impl Cli {
198 pub fn resolve_output_mode(&self) -> Result<OutputMode, AppError> {
199 if self.json && matches!(self.format, Some(OutputFormat::Text)) {
200 return Err(AppError::usage(
201 "invalid output mode: --json cannot be combined with --format text",
202 ));
203 }
204
205 if self.json || matches!(self.format, Some(OutputFormat::Json)) {
206 return Ok(OutputMode::Json);
207 }
208
209 Ok(OutputMode::Text)
210 }
211
212 pub fn command_id(&self) -> &'static str {
213 match self.command {
214 MemoCommand::Add(_) => "memo-cli add",
215 MemoCommand::Update(_) => "memo-cli update",
216 MemoCommand::Delete(_) => "memo-cli delete",
217 MemoCommand::List(_) => "memo-cli list",
218 MemoCommand::Search(_) => "memo-cli search",
219 MemoCommand::Report(_) => "memo-cli report",
220 MemoCommand::Fetch(_) => "memo-cli fetch",
221 MemoCommand::Apply(_) => "memo-cli apply",
222 }
223 }
224
225 pub fn schema_version(&self) -> &'static str {
226 match self.command {
227 MemoCommand::Add(_) => "memo-cli.add.v1",
228 MemoCommand::Update(_) => "memo-cli.update.v1",
229 MemoCommand::Delete(_) => "memo-cli.delete.v1",
230 MemoCommand::List(_) => "memo-cli.list.v1",
231 MemoCommand::Search(_) => "memo-cli.search.v1",
232 MemoCommand::Report(_) => "memo-cli.report.v1",
233 MemoCommand::Fetch(_) => "memo-cli.fetch.v1",
234 MemoCommand::Apply(_) => "memo-cli.apply.v1",
235 }
236 }
237}
238
239fn default_db_path() -> PathBuf {
240 if let Some(data_home) = env::var_os("XDG_DATA_HOME") {
241 return PathBuf::from(data_home).join("nils-cli").join("memo.db");
242 }
243
244 if let Some(home) = env::var_os("HOME") {
245 return PathBuf::from(home)
246 .join(".local")
247 .join("share")
248 .join("nils-cli")
249 .join("memo.db");
250 }
251
252 PathBuf::from("memo.db")
253}
254
255#[cfg(test)]
256pub(crate) mod tests {
257 use clap::{CommandFactory, Parser};
258
259 use super::{Cli, OutputMode};
260
261 #[test]
262 fn output_mode_defaults_to_text() {
263 let cli = Cli::parse_from(["memo-cli", "list"]);
264 let mode = cli.resolve_output_mode().expect("mode should resolve");
265 assert_eq!(mode, OutputMode::Text);
266 }
267
268 #[test]
269 fn output_mode_json_flag_wins() {
270 let cli = Cli::parse_from(["memo-cli", "--json", "list"]);
271 let mode = cli.resolve_output_mode().expect("mode should resolve");
272 assert_eq!(mode, OutputMode::Json);
273 }
274
275 #[test]
276 fn output_mode_format_json_is_supported() {
277 let cli = Cli::parse_from(["memo-cli", "--format", "json", "list"]);
278 let mode = cli.resolve_output_mode().expect("mode should resolve");
279 assert_eq!(mode, OutputMode::Json);
280 }
281
282 #[test]
283 fn output_mode_rejects_conflict() {
284 let cli = Cli::parse_from(["memo-cli", "--json", "--format", "text", "list"]);
285 let err = cli.resolve_output_mode().expect_err("conflict should fail");
286 assert_eq!(err.exit_code(), 64);
287 }
288
289 #[test]
290 fn parser_exposes_expected_subcommands() {
291 let mut cmd = Cli::command();
292 let subcommands = cmd
293 .get_subcommands_mut()
294 .map(|sub| sub.get_name().to_string())
295 .collect::<Vec<_>>();
296 assert!(subcommands.contains(&"add".to_string()));
297 assert!(subcommands.contains(&"update".to_string()));
298 assert!(subcommands.contains(&"delete".to_string()));
299 assert!(subcommands.contains(&"list".to_string()));
300 assert!(subcommands.contains(&"search".to_string()));
301 assert!(subcommands.contains(&"report".to_string()));
302 assert!(subcommands.contains(&"fetch".to_string()));
303 assert!(subcommands.contains(&"apply".to_string()));
304 }
305}