Skip to main content

kimun_notes/cli/commands/
note_ops.rs

1// tui/src/cli/commands/note_ops.rs
2//
3// CLI commands for note create, append, and show operations.
4
5use clap::Subcommand;
6use color_eyre::eyre::Result;
7use kimun_core::{NoteVault, error::VaultError};
8
9const NOTE_SEPARATOR: &str =
10    "================================================================================";
11
12#[derive(Subcommand, Debug)]
13pub enum NoteSubcommand {
14    /// Create a new note (fails if the note already exists)
15    Create {
16        /// Note path, relative to quick_note_path or absolute from vault root
17        path: String,
18        /// Note content (reads from stdin if omitted and stdin is not a TTY)
19        content: Option<String>,
20    },
21    /// Append text to a note (creates the note if it does not exist)
22    Append {
23        /// Note path, relative to quick_note_path or absolute from vault root
24        path: String,
25        /// Text to append (reads from stdin if omitted and stdin is not a TTY)
26        content: Option<String>,
27    },
28    /// Quickly capture a thought into a timestamped inbox note
29    Quick {
30        /// Text content (reads from stdin if omitted and stdin is not a TTY)
31        content: Option<String>,
32    },
33    /// List inbox notes for triage
34    Triage,
35    /// Show note content and metadata (read one or more notes)
36    Show {
37        /// One or more note paths (relative to quick_note_path or absolute from vault root)
38        paths: Vec<String>,
39        #[arg(long, value_enum, default_value = "text")]
40        format: crate::cli::output::OutputFormat,
41    },
42    /// Overwrite a note's entire content (requires --force; the old content is backed up)
43    Overwrite {
44        /// Note path, relative to quick_note_path or absolute from vault root
45        path: String,
46        /// New content (reads from stdin if omitted and stdin is not a TTY)
47        content: Option<String>,
48        /// Required: discards the existing note body
49        #[arg(long)]
50        force: bool,
51    },
52    /// Replace text in a note (literal by default; the match must be unique unless --all)
53    Replace {
54        /// Note path, relative to quick_note_path or absolute from vault root
55        path: String,
56        /// Text to find (a regular expression when --regex is set)
57        old: String,
58        /// Replacement text ($1/${name} capture references work with --regex)
59        new: String,
60        /// Replace every occurrence instead of requiring a unique match
61        #[arg(long)]
62        all: bool,
63        /// Treat the find text as a regular expression instead of a literal substring
64        #[arg(long)]
65        regex: bool,
66        /// Print the resulting note content without writing it (dry run)
67        #[arg(long)]
68        preview: bool,
69    },
70    /// Delete a note (requires --force; the content is backed up first)
71    Delete {
72        /// Note path, relative to quick_note_path or absolute from vault root
73        path: String,
74        /// Required: confirms the deletion
75        #[arg(long)]
76        force: bool,
77    },
78}
79
80pub async fn run(
81    subcommand: NoteSubcommand,
82    vault: &NoteVault,
83    quick_note_path: &str,
84    workspace_name: &str,
85) -> Result<()> {
86    match subcommand {
87        NoteSubcommand::Create { path, content } => {
88            run_create(vault, &path, content, quick_note_path).await
89        }
90        NoteSubcommand::Append { path, content } => {
91            run_append(vault, &path, content, quick_note_path).await
92        }
93        NoteSubcommand::Quick { content } => run_quick(vault, content).await,
94        NoteSubcommand::Triage => run_triage(vault).await,
95        NoteSubcommand::Show { paths, format } => {
96            use std::io::IsTerminal;
97            let reader = if std::io::stdin().is_terminal() {
98                None
99            } else {
100                Some(std::io::BufReader::new(std::io::stdin().lock()))
101            };
102            let resolved = resolve_show_paths(paths, reader)?;
103            run_show(vault, &resolved, quick_note_path, format, workspace_name).await
104        }
105        NoteSubcommand::Overwrite {
106            path,
107            content,
108            force,
109        } => run_overwrite(vault, &path, content, force, quick_note_path).await,
110        NoteSubcommand::Replace {
111            path,
112            old,
113            new,
114            all,
115            regex,
116            preview,
117        } => {
118            run_replace(
119                vault,
120                &path,
121                &old,
122                &new,
123                all,
124                regex,
125                preview,
126                quick_note_path,
127            )
128            .await
129        }
130        NoteSubcommand::Delete { path, force } => {
131            run_delete(vault, &path, force, quick_note_path).await
132        }
133    }
134}
135
136async fn run_overwrite(
137    vault: &NoteVault,
138    path_input: &str,
139    content: Option<String>,
140    force: bool,
141    quick_note_path: &str,
142) -> Result<()> {
143    use crate::cli::helpers::{resolve_content, resolve_note_path};
144
145    if !force {
146        return Err(color_eyre::eyre::eyre!(
147            "Refusing to overwrite without --force (this discards the existing note body)"
148        ));
149    }
150    let vault_path = resolve_note_path(path_input, quick_note_path)?;
151    let text = resolve_content(content)?;
152    if text.is_empty() {
153        return Err(color_eyre::eyre::eyre!(
154            "Refusing to overwrite with empty content (this would wipe the note); pass content, or use `note delete` to remove it"
155        ));
156    }
157
158    vault
159        .save_note(&vault_path, &text)
160        .await
161        .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
162
163    println!("Note saved: {}", vault_path);
164    Ok(())
165}
166
167#[allow(clippy::too_many_arguments)]
168async fn run_replace(
169    vault: &NoteVault,
170    path_input: &str,
171    old: &str,
172    new: &str,
173    all: bool,
174    regex: bool,
175    preview: bool,
176    quick_note_path: &str,
177) -> Result<()> {
178    use crate::cli::helpers::resolve_note_path;
179
180    let vault_path = resolve_note_path(path_input, quick_note_path)?;
181
182    if preview {
183        let pv = vault
184            .preview_replace(&vault_path, old, new, all, regex)
185            .await
186            .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
187        // Report the count on stderr so stdout is just the resulting content
188        // (pipe-friendly, e.g. into a diff).
189        eprintln!(
190            "{} occurrence(s) would be replaced in {} (preview — not written)",
191            pv.count, vault_path
192        );
193        print!("{}", pv.content);
194        return Ok(());
195    }
196
197    let count = vault
198        .replace_in_note(&vault_path, old, new, all, regex)
199        .await
200        .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
201
202    println!("Replaced {} occurrence(s) in {}", count, vault_path);
203    Ok(())
204}
205
206async fn run_delete(
207    vault: &NoteVault,
208    path_input: &str,
209    force: bool,
210    quick_note_path: &str,
211) -> Result<()> {
212    use crate::cli::helpers::resolve_note_path;
213
214    if !force {
215        return Err(color_eyre::eyre::eyre!(
216            "Refusing to delete without --force"
217        ));
218    }
219    let vault_path = resolve_note_path(path_input, quick_note_path)?;
220
221    vault
222        .delete_note(&vault_path)
223        .await
224        .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
225
226    println!("Note deleted: {}", vault_path);
227    Ok(())
228}
229
230async fn run_create(
231    vault: &NoteVault,
232    path_input: &str,
233    content: Option<String>,
234    quick_note_path: &str,
235) -> Result<()> {
236    use crate::cli::helpers::{resolve_content, resolve_note_path};
237
238    let vault_path = resolve_note_path(path_input, quick_note_path)?;
239    let text = resolve_content(content)?;
240
241    vault
242        .create_note(&vault_path, &text)
243        .await
244        .map_err(|e| match &e {
245            VaultError::NoteExists { path } => {
246                color_eyre::eyre::eyre!("Note already exists: {}", path)
247            }
248            _ => color_eyre::eyre::eyre!("{}", e),
249        })?;
250
251    println!("Note saved: {}", vault_path);
252    Ok(())
253}
254
255async fn run_append(
256    vault: &NoteVault,
257    path_input: &str,
258    content: Option<String>,
259    quick_note_path: &str,
260) -> Result<()> {
261    use crate::cli::helpers::{resolve_content, resolve_note_path};
262
263    let vault_path = resolve_note_path(path_input, quick_note_path)?;
264    let text = resolve_content(content)?;
265
266    if text.is_empty() {
267        return Ok(());
268    }
269
270    vault
271        .append_to_note(&vault_path, &text, None)
272        .await
273        .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
274
275    println!("Note saved: {}", vault_path);
276    Ok(())
277}
278
279pub(crate) fn format_note_show_text(
280    path: &kimun_core::nfs::VaultPath,
281    content: &str,
282    title: &str,
283    tags: &[String],
284    links: &[String],
285    backlinks: &[String],
286) -> String {
287    let mut out = String::new();
288    out.push_str(&format!("Path:      {}\n", path));
289    if !title.is_empty() {
290        out.push_str(&format!("Title:     {}\n", title));
291    }
292    if !tags.is_empty() {
293        out.push_str(&format!("Tags:      {}\n", tags.join(" ")));
294    }
295    if !links.is_empty() {
296        out.push_str(&format!("Links:     {}\n", links.join(", ")));
297    }
298    if !backlinks.is_empty() {
299        out.push_str(&format!("Backlinks: {}\n", backlinks.join(", ")));
300    }
301    out.push_str("---\n");
302    out.push_str(content);
303    out
304}
305
306/// Resolves the effective path list for `note show`.
307/// - If `args` is non-empty, returns it directly (reader is ignored).
308/// - If `args` is empty and `reader` is `Some`, reads non-blank trimmed lines from it.
309/// - If `args` is empty and `reader` is `None` (TTY), returns an error.
310fn resolve_show_paths<R: std::io::BufRead>(
311    args: Vec<String>,
312    reader: Option<R>,
313) -> color_eyre::eyre::Result<Vec<String>> {
314    if !args.is_empty() {
315        return Ok(args);
316    }
317    match reader {
318        Some(r) => {
319            let paths: Result<Vec<String>, _> = r
320                .lines()
321                .filter(|l| l.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(true))
322                .map(|l| l.map(|s| s.trim().split('\t').next().unwrap_or("").to_owned()))
323                .collect();
324            let paths =
325                paths.map_err(|e| color_eyre::eyre::eyre!("Failed to read stdin: {}", e))?;
326            if paths.is_empty() {
327                return Err(color_eyre::eyre::eyre!(
328                    "No paths provided — pass paths as arguments or pipe from stdin"
329                ));
330            }
331            Ok(paths)
332        }
333        None => Err(color_eyre::eyre::eyre!(
334            "No paths provided — pass paths as arguments or pipe from stdin"
335        )),
336    }
337}
338
339async fn run_show(
340    vault: &NoteVault,
341    path_inputs: &[String],
342    quick_note_path: &str,
343    format: crate::cli::output::OutputFormat,
344    workspace_name: &str,
345) -> Result<()> {
346    use crate::cli::helpers::resolve_note_path;
347    use crate::cli::json_output::{
348        JsonNoteEntry, JsonNoteMetadata, JsonOutput, JsonOutputMetadata,
349    };
350    use crate::cli::metadata_extractor::{extract_headers, extract_links, extract_tags};
351    use crate::cli::output::OutputFormat;
352    use chrono::Utc;
353    use kimun_core::error::{FSError, VaultError};
354    use kimun_core::nfs::NoteEntryData;
355    use std::time::UNIX_EPOCH;
356
357    if matches!(format, OutputFormat::Paths) {
358        return Err(color_eyre::eyre::eyre!(
359            "--format paths is not valid for note show; use 'text' or 'json'"
360        ));
361    }
362
363    // One accumulator per format — only the active one is ever populated.
364    enum Accumulator {
365        Text(Vec<String>),
366        Json(Vec<JsonNoteEntry>),
367    }
368
369    let mut acc = match format {
370        OutputFormat::Text => Accumulator::Text(Vec::new()),
371        OutputFormat::Json => Accumulator::Json(Vec::new()),
372        OutputFormat::Paths => unreachable!("guarded above"),
373    };
374    let mut had_errors = false;
375
376    for input in path_inputs {
377        let vault_path = match resolve_note_path(input, quick_note_path) {
378            Ok(p) => p,
379            Err(e) => {
380                eprintln!("Error: {}", e);
381                had_errors = true;
382                continue;
383            }
384        };
385
386        let note_details = match vault.load_note(&vault_path).await {
387            Ok(nd) => nd,
388            Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
389                eprintln!("Error: Note not found: {}", vault_path);
390                had_errors = true;
391                continue;
392            }
393            Err(e) => return Err(color_eyre::eyre::eyre!("{}", e)),
394        };
395
396        let content = &note_details.raw_text;
397        let content_data = note_details.get_content_data();
398
399        let backlink_results = vault
400            .get_backlinks(&vault_path)
401            .await
402            .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
403        let backlink_paths: Vec<String> = backlink_results
404            .iter()
405            .map(|(e, _)| e.path.to_string())
406            .collect();
407
408        match &mut acc {
409            Accumulator::Text(entries) => {
410                let tags = extract_tags(content);
411                let links = extract_links(content);
412                entries.push(format_note_show_text(
413                    &vault_path,
414                    content,
415                    &content_data.title,
416                    &tags,
417                    &links,
418                    &backlink_paths,
419                ));
420            }
421            Accumulator::Json(entries) => {
422                let meta = tokio::fs::metadata(vault.path_to_pathbuf(&vault_path))
423                    .await
424                    .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
425                let modified_secs = meta
426                    .modified()
427                    .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs())
428                    .unwrap_or(0);
429                let entry_data = NoteEntryData {
430                    path: vault_path.clone(),
431                    size: meta.len(),
432                    modified_secs,
433                };
434                let tags = extract_tags(content);
435                let links = extract_links(content);
436                let headers = extract_headers(content);
437                let journal_date = vault
438                    .journal_date(&vault_path)
439                    .map(|d| d.format("%Y-%m-%d").to_string());
440                entries.push(JsonNoteEntry {
441                    path: vault_path.to_string_with_ext(),
442                    title: content_data.title.clone(),
443                    content: content.clone(),
444                    size: entry_data.size,
445                    modified: entry_data.modified_secs,
446                    created: entry_data.modified_secs, // TODO: track actual creation time
447                    hash: format!("{:x}", content_data.hash),
448                    journal_date,
449                    metadata: JsonNoteMetadata {
450                        tags,
451                        links,
452                        headers,
453                    },
454                    backlinks: if backlink_paths.is_empty() {
455                        None
456                    } else {
457                        Some(backlink_paths)
458                    },
459                });
460            }
461        }
462    }
463
464    let is_empty = match &acc {
465        Accumulator::Text(v) => v.is_empty(),
466        Accumulator::Json(v) => v.is_empty(),
467    };
468    if is_empty {
469        return Err(color_eyre::eyre::eyre!(
470            "No notes found — all specified paths were missing"
471        ));
472    }
473
474    // Output whatever was found — the JSON/text is valid for the notes that succeeded.
475    // had_errors (non-zero exit) signals that some notes were missing; those were
476    // already reported to stderr in the loop above.
477    match acc {
478        Accumulator::Text(entries) => {
479            let sep = format!("\n{}\n\n", NOTE_SEPARATOR);
480            print!("{}", entries.join(&sep));
481        }
482        Accumulator::Json(notes) => {
483            let output = JsonOutput {
484                metadata: JsonOutputMetadata {
485                    workspace: workspace_name.to_string(),
486                    workspace_path: vault.workspace_path().to_string_lossy().to_string(),
487                    total_results: notes.len(),
488                    query: None,
489                    is_listing: false,
490                    generated_at: Utc::now().to_rfc3339(),
491                },
492                notes,
493            };
494            print!(
495                "{}",
496                serde_json::to_string(&output).map_err(|e| color_eyre::eyre::eyre!("{}", e))?
497            );
498        }
499    }
500
501    if had_errors {
502        return Err(color_eyre::eyre::eyre!(
503            "One or more notes could not be found"
504        ));
505    }
506
507    Ok(())
508}
509
510async fn run_triage(vault: &NoteVault) -> Result<()> {
511    let inbox_notes = vault
512        .get_notes(vault.inbox_path(), false)
513        .await
514        .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
515
516    if inbox_notes.is_empty() {
517        println!("Inbox is empty.");
518        return Ok(());
519    }
520
521    println!("Inbox notes ({}):\n", inbox_notes.len());
522    for (entry, content_data) in &inbox_notes {
523        let title = if content_data.title.trim().is_empty() {
524            "<no title>"
525        } else {
526            &content_data.title
527        };
528        println!("  {} — {}", entry.path, title);
529    }
530
531    Ok(())
532}
533
534async fn run_quick(vault: &NoteVault, content: Option<String>) -> Result<()> {
535    use crate::cli::helpers::resolve_content;
536
537    let text = resolve_content(content)?;
538    if text.is_empty() {
539        return Ok(());
540    }
541
542    let details = vault
543        .quick_note(&text)
544        .await
545        .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
546
547    println!("Note saved: {}", details.path);
548    Ok(())
549}
550
551#[cfg(test)]
552mod tests {
553    use super::resolve_show_paths;
554    use std::io::Cursor;
555
556    #[test]
557    fn test_resolve_show_paths_uses_args_when_given() {
558        let args = vec!["projects/foo".to_string(), "inbox/bar".to_string()];
559        let result = resolve_show_paths(args.clone(), None::<Cursor<&[u8]>>).unwrap();
560        assert_eq!(result, args);
561    }
562
563    #[test]
564    fn test_resolve_show_paths_reads_from_reader() {
565        let input = b"projects/foo\ninbox/bar\n";
566        let reader = Cursor::new(input.as_ref());
567        let result = resolve_show_paths(vec![], Some(reader)).unwrap();
568        assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
569    }
570
571    #[test]
572    fn test_resolve_show_paths_skips_blank_lines() {
573        let input = b"projects/foo\n\n  \ninbox/bar\n";
574        let reader = Cursor::new(input.as_ref());
575        let result = resolve_show_paths(vec![], Some(reader)).unwrap();
576        assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
577    }
578
579    #[test]
580    fn test_resolve_show_paths_all_blank_stdin_returns_empty() {
581        let input = b"\n  \n\t\n";
582        let reader = Cursor::new(input.as_ref());
583        let result = resolve_show_paths(vec![], Some(reader));
584        assert!(result.is_err());
585        let msg = result.unwrap_err().to_string();
586        assert!(msg.contains("No paths provided"), "got: {}", msg);
587    }
588
589    #[test]
590    fn test_resolve_show_paths_strips_tab_separated_fields() {
591        // kimun notes outputs tab-separated lines: path\ttitle\tsize\ttimestamp
592        let input = b"projects/foo\tFoo Note\t1234\t1700000000\ninbox/bar\tBar\t42\t1700000001\n";
593        let reader = Cursor::new(input.as_ref());
594        let result = resolve_show_paths(vec![], Some(reader)).unwrap();
595        assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
596    }
597
598    #[test]
599    fn test_resolve_show_paths_no_args_no_reader_errors() {
600        let result = resolve_show_paths(vec![], None::<Cursor<&[u8]>>);
601        assert!(result.is_err());
602        let msg = result.unwrap_err().to_string();
603        assert!(msg.contains("No paths provided"), "got: {}", msg);
604    }
605}