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 List(ListArgs),
73 Search(SearchArgs),
75 Report(ReportArgs),
77 Fetch(FetchArgs),
79 Apply(ApplyArgs),
81}
82
83#[derive(Debug, clap::Args)]
84pub struct AddArgs {
85 pub text: String,
87
88 #[arg(long, default_value = "cli")]
90 pub source: String,
91
92 #[arg(long)]
94 pub at: Option<String>,
95}
96
97#[derive(Debug, clap::Args)]
98pub struct ListArgs {
99 #[arg(long, default_value_t = 20)]
101 pub limit: usize,
102
103 #[arg(long, default_value_t = 0)]
105 pub offset: usize,
106
107 #[arg(long, value_enum, default_value_t = ItemState::All)]
109 pub state: ItemState,
110}
111
112#[derive(Debug, clap::Args)]
113pub struct SearchArgs {
114 pub query: String,
116
117 #[arg(long, default_value_t = 20)]
119 pub limit: usize,
120
121 #[arg(long, value_enum, default_value_t = ItemState::All)]
123 pub state: ItemState,
124}
125
126#[derive(Debug, clap::Args)]
127pub struct ReportArgs {
128 pub period: ReportPeriod,
130
131 #[arg(long)]
133 pub tz: Option<String>,
134
135 #[arg(long)]
137 pub from: Option<String>,
138
139 #[arg(long)]
141 pub to: Option<String>,
142}
143
144#[derive(Debug, clap::Args)]
145pub struct FetchArgs {
146 #[arg(long, default_value_t = 50)]
148 pub limit: usize,
149
150 #[arg(long)]
152 pub cursor: Option<String>,
153
154 #[arg(long, value_enum, default_value_t = FetchState::Pending)]
156 pub state: FetchState,
157}
158
159#[derive(Debug, clap::Args)]
160pub struct ApplyArgs {
161 #[arg(long)]
163 pub input: Option<PathBuf>,
164
165 #[arg(long)]
167 pub stdin: bool,
168
169 #[arg(long)]
171 pub dry_run: bool,
172}
173
174impl Cli {
175 pub fn resolve_output_mode(&self) -> Result<OutputMode, AppError> {
176 if self.json && matches!(self.format, Some(OutputFormat::Text)) {
177 return Err(AppError::usage(
178 "invalid output mode: --json cannot be combined with --format text",
179 ));
180 }
181
182 if self.json || matches!(self.format, Some(OutputFormat::Json)) {
183 return Ok(OutputMode::Json);
184 }
185
186 Ok(OutputMode::Text)
187 }
188
189 pub fn command_id(&self) -> &'static str {
190 match self.command {
191 MemoCommand::Add(_) => "memo-cli add",
192 MemoCommand::List(_) => "memo-cli list",
193 MemoCommand::Search(_) => "memo-cli search",
194 MemoCommand::Report(_) => "memo-cli report",
195 MemoCommand::Fetch(_) => "memo-cli fetch",
196 MemoCommand::Apply(_) => "memo-cli apply",
197 }
198 }
199
200 pub fn schema_version(&self) -> &'static str {
201 match self.command {
202 MemoCommand::Add(_) => "memo-cli.add.v1",
203 MemoCommand::List(_) => "memo-cli.list.v1",
204 MemoCommand::Search(_) => "memo-cli.search.v1",
205 MemoCommand::Report(_) => "memo-cli.report.v1",
206 MemoCommand::Fetch(_) => "memo-cli.fetch.v1",
207 MemoCommand::Apply(_) => "memo-cli.apply.v1",
208 }
209 }
210}
211
212fn default_db_path() -> PathBuf {
213 if let Some(data_home) = env::var_os("XDG_DATA_HOME") {
214 return PathBuf::from(data_home).join("nils-cli").join("memo.db");
215 }
216
217 if let Some(home) = env::var_os("HOME") {
218 return PathBuf::from(home)
219 .join(".local")
220 .join("share")
221 .join("nils-cli")
222 .join("memo.db");
223 }
224
225 PathBuf::from("memo.db")
226}
227
228#[cfg(test)]
229pub(crate) mod tests {
230 use clap::{CommandFactory, Parser};
231
232 use super::{Cli, OutputMode};
233
234 #[test]
235 fn output_mode_defaults_to_text() {
236 let cli = Cli::parse_from(["memo-cli", "list"]);
237 let mode = cli.resolve_output_mode().expect("mode should resolve");
238 assert_eq!(mode, OutputMode::Text);
239 }
240
241 #[test]
242 fn output_mode_json_flag_wins() {
243 let cli = Cli::parse_from(["memo-cli", "--json", "list"]);
244 let mode = cli.resolve_output_mode().expect("mode should resolve");
245 assert_eq!(mode, OutputMode::Json);
246 }
247
248 #[test]
249 fn output_mode_format_json_is_supported() {
250 let cli = Cli::parse_from(["memo-cli", "--format", "json", "list"]);
251 let mode = cli.resolve_output_mode().expect("mode should resolve");
252 assert_eq!(mode, OutputMode::Json);
253 }
254
255 #[test]
256 fn output_mode_rejects_conflict() {
257 let cli = Cli::parse_from(["memo-cli", "--json", "--format", "text", "list"]);
258 let err = cli.resolve_output_mode().expect_err("conflict should fail");
259 assert_eq!(err.exit_code(), 64);
260 }
261
262 #[test]
263 fn parser_exposes_expected_subcommands() {
264 let mut cmd = Cli::command();
265 let subcommands = cmd
266 .get_subcommands_mut()
267 .map(|sub| sub.get_name().to_string())
268 .collect::<Vec<_>>();
269 assert!(subcommands.contains(&"add".to_string()));
270 assert!(subcommands.contains(&"list".to_string()));
271 assert!(subcommands.contains(&"search".to_string()));
272 assert!(subcommands.contains(&"report".to_string()));
273 assert!(subcommands.contains(&"fetch".to_string()));
274 assert!(subcommands.contains(&"apply".to_string()));
275 }
276}