1use 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 {
16 path: String,
18 content: Option<String>,
20 },
21 Append {
23 path: String,
25 content: Option<String>,
27 },
28 Quick {
30 content: Option<String>,
32 },
33 Triage,
35 Show {
37 paths: Vec<String>,
39 #[arg(long, value_enum, default_value = "text")]
40 format: crate::cli::output::OutputFormat,
41 },
42 Overwrite {
44 path: String,
46 content: Option<String>,
48 #[arg(long)]
50 force: bool,
51 },
52 Replace {
54 path: String,
56 old: String,
58 new: String,
60 #[arg(long)]
62 all: bool,
63 #[arg(long)]
65 regex: bool,
66 #[arg(long)]
68 preview: bool,
69 },
70 Delete {
72 path: String,
74 #[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 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
306fn 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 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 = ¬e_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, 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 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 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}