1use clap::Subcommand;
6use color_eyre::eyre::Result;
7use kimun_core::{NoteVault, error::VaultError};
8
9const NOTE_SEPARATOR: &str = "================================================================================";
10
11#[derive(Subcommand, Debug)]
12pub enum NoteSubcommand {
13 Create {
15 path: String,
17 content: Option<String>,
19 },
20 Append {
22 path: String,
24 content: Option<String>,
26 },
27 Quick {
29 content: Option<String>,
31 },
32 Triage,
34 Show {
36 paths: Vec<String>,
38 #[arg(long, value_enum, default_value = "text")]
39 format: crate::cli::output::OutputFormat,
40 },
41}
42
43pub async fn run(
44 subcommand: NoteSubcommand,
45 vault: &NoteVault,
46 quick_note_path: &str,
47 workspace_name: &str,
48) -> Result<()> {
49 match subcommand {
50 NoteSubcommand::Create { path, content } => {
51 run_create(vault, &path, content, quick_note_path).await
52 }
53 NoteSubcommand::Append { path, content } => {
54 run_append(vault, &path, content, quick_note_path).await
55 }
56 NoteSubcommand::Quick { content } => run_quick(vault, content).await,
57 NoteSubcommand::Triage => run_triage(vault).await,
58 NoteSubcommand::Show { paths, format } => {
59 use std::io::IsTerminal;
60 let reader = if std::io::stdin().is_terminal() {
61 None
62 } else {
63 Some(std::io::BufReader::new(std::io::stdin().lock()))
64 };
65 let resolved = resolve_show_paths(paths, reader)?;
66 run_show(vault, &resolved, quick_note_path, format, workspace_name).await
67 }
68 }
69}
70
71async fn run_create(
72 vault: &NoteVault,
73 path_input: &str,
74 content: Option<String>,
75 quick_note_path: &str,
76) -> Result<()> {
77 use crate::cli::helpers::{resolve_note_path, resolve_content};
78
79 let vault_path = resolve_note_path(path_input, quick_note_path)?;
80 let text = resolve_content(content)?;
81
82 vault.create_note(&vault_path, &text).await.map_err(|e| {
83 match &e {
84 VaultError::NoteExists { path } => {
85 color_eyre::eyre::eyre!("Note already exists: {}", path)
86 }
87 _ => color_eyre::eyre::eyre!("{}", e),
88 }
89 })?;
90
91 println!("Note saved: {}", vault_path);
92 Ok(())
93}
94
95async fn run_append(
96 vault: &NoteVault,
97 path_input: &str,
98 content: Option<String>,
99 quick_note_path: &str,
100) -> Result<()> {
101 use crate::cli::helpers::{resolve_note_path, resolve_content};
102 use kimun_core::error::FSError;
103
104 let vault_path = resolve_note_path(path_input, quick_note_path)?;
105 let text = resolve_content(content)?;
106
107 if text.is_empty() {
108 return Ok(());
109 }
110
111 match vault.get_note_text(&vault_path).await {
112 Ok(existing) => {
113 let combined = format!("{}\n{}", existing, text);
114 vault.save_note(&vault_path, &combined).await
115 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
116 }
117 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
118 match vault.create_note(&vault_path, &text).await {
119 Ok(_) => {}
120 Err(VaultError::NoteExists { .. }) => {
121 let existing = vault.get_note_text(&vault_path).await
123 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
124 let combined = format!("{}\n{}", existing, text);
125 vault.save_note(&vault_path, &combined).await
126 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
127 }
128 Err(e) => return Err(color_eyre::eyre::eyre!("{}", e)),
129 }
130 }
131 Err(e) => return Err(color_eyre::eyre::eyre!("{}", e)),
132 }
133
134 println!("Note saved: {}", vault_path);
135 Ok(())
136}
137
138pub(crate) fn format_note_show_text(
139 path: &kimun_core::nfs::VaultPath,
140 content: &str,
141 title: &str,
142 tags: &[String],
143 links: &[String],
144 backlinks: &[String],
145) -> String {
146 let mut out = String::new();
147 out.push_str(&format!("Path: {}\n", path));
148 if !title.is_empty() {
149 out.push_str(&format!("Title: {}\n", title));
150 }
151 if !tags.is_empty() {
152 out.push_str(&format!("Tags: {}\n", tags.join(" ")));
153 }
154 if !links.is_empty() {
155 out.push_str(&format!("Links: {}\n", links.join(", ")));
156 }
157 if !backlinks.is_empty() {
158 out.push_str(&format!("Backlinks: {}\n", backlinks.join(", ")));
159 }
160 out.push_str("---\n");
161 out.push_str(content);
162 out
163}
164
165fn resolve_show_paths<R: std::io::BufRead>(
170 args: Vec<String>,
171 reader: Option<R>,
172) -> color_eyre::eyre::Result<Vec<String>> {
173 if !args.is_empty() {
174 return Ok(args);
175 }
176 match reader {
177 Some(r) => {
178 let paths: Result<Vec<String>, _> = r
179 .lines()
180 .filter(|l| l.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(true))
181 .map(|l| l.map(|s| s.trim().split('\t').next().unwrap_or("").to_owned()))
182 .collect();
183 let paths = paths.map_err(|e| color_eyre::eyre::eyre!("Failed to read stdin: {}", e))?;
184 if paths.is_empty() {
185 return Err(color_eyre::eyre::eyre!(
186 "No paths provided — pass paths as arguments or pipe from stdin"
187 ));
188 }
189 Ok(paths)
190 }
191 None => Err(color_eyre::eyre::eyre!(
192 "No paths provided — pass paths as arguments or pipe from stdin"
193 )),
194 }
195}
196
197async fn run_show(
198 vault: &NoteVault,
199 path_inputs: &[String],
200 quick_note_path: &str,
201 format: crate::cli::output::OutputFormat,
202 workspace_name: &str,
203) -> Result<()> {
204 use crate::cli::helpers::resolve_note_path;
205 use crate::cli::metadata_extractor::{extract_tags, extract_links, extract_headers};
206 use crate::cli::json_output::{JsonNoteEntry, JsonNoteMetadata, JsonOutput, JsonOutputMetadata};
207 use crate::cli::output::OutputFormat;
208 use kimun_core::nfs::NoteEntryData;
209 use kimun_core::error::{VaultError, FSError};
210 use chrono::Utc;
211 use std::time::UNIX_EPOCH;
212
213 if matches!(format, OutputFormat::Paths) {
214 return Err(color_eyre::eyre::eyre!(
215 "--format paths is not valid for note show; use 'text' or 'json'"
216 ));
217 }
218
219 enum Accumulator {
221 Text(Vec<String>),
222 Json(Vec<JsonNoteEntry>),
223 }
224
225 let mut acc = match format {
226 OutputFormat::Text => Accumulator::Text(Vec::new()),
227 OutputFormat::Json => Accumulator::Json(Vec::new()),
228 OutputFormat::Paths => unreachable!("guarded above"),
229 };
230 let mut had_errors = false;
231
232 for input in path_inputs {
233 let vault_path = match resolve_note_path(input, quick_note_path) {
234 Ok(p) => p,
235 Err(e) => {
236 eprintln!("Error: {}", e);
237 had_errors = true;
238 continue;
239 }
240 };
241
242 let note_details = match vault.load_note(&vault_path).await {
243 Ok(nd) => nd,
244 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
245 eprintln!("Error: Note not found: {}", vault_path);
246 had_errors = true;
247 continue;
248 }
249 Err(e) => return Err(color_eyre::eyre::eyre!("{}", e)),
250 };
251
252 let content = ¬e_details.raw_text;
253 let content_data = note_details.get_content_data();
254
255 let backlink_results = vault
256 .get_backlinks(&vault_path)
257 .await
258 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
259 let backlink_paths: Vec<String> = backlink_results
260 .iter()
261 .map(|(e, _)| e.path.to_string())
262 .collect();
263
264 match &mut acc {
265 Accumulator::Text(entries) => {
266 let tags = extract_tags(content);
267 let links = extract_links(content);
268 entries.push(format_note_show_text(
269 &vault_path,
270 content,
271 &content_data.title,
272 &tags,
273 &links,
274 &backlink_paths,
275 ));
276 }
277 Accumulator::Json(entries) => {
278 let meta = tokio::fs::metadata(vault.path_to_pathbuf(&vault_path))
279 .await
280 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
281 let modified_secs = meta
282 .modified()
283 .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs())
284 .unwrap_or(0);
285 let entry_data = NoteEntryData {
286 path: vault_path.clone(),
287 size: meta.len(),
288 modified_secs,
289 };
290 let tags = extract_tags(content);
291 let links = extract_links(content);
292 let headers = extract_headers(content);
293 let journal_date = vault
294 .journal_date(&vault_path)
295 .map(|d| d.format("%Y-%m-%d").to_string());
296 entries.push(JsonNoteEntry {
297 path: vault_path.to_string_with_ext(),
298 title: content_data.title.clone(),
299 content: content.clone(),
300 size: entry_data.size,
301 modified: entry_data.modified_secs,
302 created: entry_data.modified_secs, hash: format!("{:x}", content_data.hash),
304 journal_date,
305 metadata: JsonNoteMetadata { tags, links, headers },
306 backlinks: if backlink_paths.is_empty() { None } else { Some(backlink_paths) },
307 });
308 }
309 }
310 }
311
312 let is_empty = match &acc {
313 Accumulator::Text(v) => v.is_empty(),
314 Accumulator::Json(v) => v.is_empty(),
315 };
316 if is_empty {
317 return Err(color_eyre::eyre::eyre!(
318 "No notes found — all specified paths were missing"
319 ));
320 }
321
322 match acc {
326 Accumulator::Text(entries) => {
327 let sep = format!("\n{}\n\n", NOTE_SEPARATOR);
328 print!("{}", entries.join(&sep));
329 }
330 Accumulator::Json(notes) => {
331 let output = JsonOutput {
332 metadata: JsonOutputMetadata {
333 workspace: workspace_name.to_string(),
334 workspace_path: vault.workspace_path.to_string_lossy().to_string(),
335 total_results: notes.len(),
336 query: None,
337 is_listing: false,
338 generated_at: Utc::now().to_rfc3339(),
339 },
340 notes,
341 };
342 print!(
343 "{}",
344 serde_json::to_string(&output)
345 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?
346 );
347 }
348 }
349
350 if had_errors {
351 return Err(color_eyre::eyre::eyre!("One or more notes could not be found"));
352 }
353
354 Ok(())
355}
356
357async fn run_triage(vault: &NoteVault) -> Result<()> {
358 let inbox_notes = vault
359 .get_notes(vault.inbox_path(), false)
360 .await
361 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
362
363 if inbox_notes.is_empty() {
364 println!("Inbox is empty.");
365 return Ok(());
366 }
367
368 println!("Inbox notes ({}):\n", inbox_notes.len());
369 for (entry, content_data) in &inbox_notes {
370 let title = if content_data.title.trim().is_empty() {
371 "<no title>"
372 } else {
373 &content_data.title
374 };
375 println!(" {} — {}", entry.path, title);
376 }
377
378 Ok(())
379}
380
381async fn run_quick(vault: &NoteVault, content: Option<String>) -> Result<()> {
382 use crate::cli::helpers::resolve_content;
383
384 let text = resolve_content(content)?;
385 if text.is_empty() {
386 return Ok(());
387 }
388
389 let details = vault
390 .quick_note(&text)
391 .await
392 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
393
394 println!("Note saved: {}", details.path);
395 Ok(())
396}
397
398#[cfg(test)]
399mod tests {
400 use super::resolve_show_paths;
401 use std::io::Cursor;
402
403 #[test]
404 fn test_resolve_show_paths_uses_args_when_given() {
405 let args = vec!["projects/foo".to_string(), "inbox/bar".to_string()];
406 let result = resolve_show_paths(args.clone(), None::<Cursor<&[u8]>>).unwrap();
407 assert_eq!(result, args);
408 }
409
410 #[test]
411 fn test_resolve_show_paths_reads_from_reader() {
412 let input = b"projects/foo\ninbox/bar\n";
413 let reader = Cursor::new(input.as_ref());
414 let result = resolve_show_paths(vec![], Some(reader)).unwrap();
415 assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
416 }
417
418 #[test]
419 fn test_resolve_show_paths_skips_blank_lines() {
420 let input = b"projects/foo\n\n \ninbox/bar\n";
421 let reader = Cursor::new(input.as_ref());
422 let result = resolve_show_paths(vec![], Some(reader)).unwrap();
423 assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
424 }
425
426 #[test]
427 fn test_resolve_show_paths_all_blank_stdin_returns_empty() {
428 let input = b"\n \n\t\n";
429 let reader = Cursor::new(input.as_ref());
430 let result = resolve_show_paths(vec![], Some(reader));
431 assert!(result.is_err());
432 let msg = result.unwrap_err().to_string();
433 assert!(msg.contains("No paths provided"), "got: {}", msg);
434 }
435
436 #[test]
437 fn test_resolve_show_paths_strips_tab_separated_fields() {
438 let input = b"projects/foo\tFoo Note\t1234\t1700000000\ninbox/bar\tBar\t42\t1700000001\n";
440 let reader = Cursor::new(input.as_ref());
441 let result = resolve_show_paths(vec![], Some(reader)).unwrap();
442 assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
443 }
444
445 #[test]
446 fn test_resolve_show_paths_no_args_no_reader_errors() {
447 let result = resolve_show_paths(vec![], None::<Cursor<&[u8]>>);
448 assert!(result.is_err());
449 let msg = result.unwrap_err().to_string();
450 assert!(msg.contains("No paths provided"), "got: {}", msg);
451 }
452}