1use clap::Subcommand;
6use color_eyre::eyre::Result;
7use kimun_core::NoteVault;
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.save_note(&vault_path, &text).await?;
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 eprintln!(
189 "{} occurrence(s) would be replaced in {} (preview — not written)",
190 pv.count, vault_path
191 );
192 print!("{}", pv.content);
193 return Ok(());
194 }
195
196 let count = vault
197 .replace_in_note(&vault_path, old, new, all, regex)
198 .await?;
199
200 println!("Replaced {} occurrence(s) in {}", count, vault_path);
201 Ok(())
202}
203
204async fn run_delete(
205 vault: &NoteVault,
206 path_input: &str,
207 force: bool,
208 quick_note_path: &str,
209) -> Result<()> {
210 use crate::cli::helpers::resolve_note_path;
211
212 if !force {
213 return Err(color_eyre::eyre::eyre!(
214 "Refusing to delete without --force"
215 ));
216 }
217 let vault_path = resolve_note_path(path_input, quick_note_path)?;
218
219 vault.delete_note(&vault_path).await?;
220
221 println!("Note deleted: {}", vault_path);
222 Ok(())
223}
224
225async fn run_create(
226 vault: &NoteVault,
227 path_input: &str,
228 content: Option<String>,
229 quick_note_path: &str,
230) -> Result<()> {
231 use crate::cli::helpers::{resolve_content, resolve_note_path};
232
233 let vault_path = resolve_note_path(path_input, quick_note_path)?;
234 let text = resolve_content(content)?;
235
236 vault.create_note(&vault_path, &text).await?;
237
238 println!("Note saved: {}", vault_path);
239 Ok(())
240}
241
242async fn run_append(
243 vault: &NoteVault,
244 path_input: &str,
245 content: Option<String>,
246 quick_note_path: &str,
247) -> Result<()> {
248 use crate::cli::helpers::{resolve_content, resolve_note_path};
249
250 let vault_path = resolve_note_path(path_input, quick_note_path)?;
251 let text = resolve_content(content)?;
252
253 if text.is_empty() {
254 return Ok(());
255 }
256
257 vault.append_to_note(&vault_path, &text, None).await?;
258
259 println!("Note saved: {}", vault_path);
260 Ok(())
261}
262
263pub(crate) fn format_note_show_text(
264 path: &kimun_core::nfs::VaultPath,
265 content: &str,
266 title: &str,
267 tags: &[String],
268 links: &[String],
269 backlinks: &[String],
270) -> String {
271 let mut out = String::new();
272 out.push_str(&format!("Path: {}\n", path));
273 if !title.is_empty() {
274 out.push_str(&format!("Title: {}\n", title));
275 }
276 if !tags.is_empty() {
277 out.push_str(&format!("Tags: {}\n", tags.join(" ")));
278 }
279 if !links.is_empty() {
280 out.push_str(&format!("Links: {}\n", links.join(", ")));
281 }
282 if !backlinks.is_empty() {
283 out.push_str(&format!("Backlinks: {}\n", backlinks.join(", ")));
284 }
285 out.push_str("---\n");
286 out.push_str(content);
287 out
288}
289
290fn resolve_show_paths<R: std::io::BufRead>(
295 args: Vec<String>,
296 reader: Option<R>,
297) -> color_eyre::eyre::Result<Vec<String>> {
298 if !args.is_empty() {
299 return Ok(args);
300 }
301 match reader {
302 Some(r) => {
303 let paths: Result<Vec<String>, _> = r
304 .lines()
305 .filter(|l| l.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(true))
306 .map(|l| l.map(|s| s.trim().split('\t').next().unwrap_or("").to_owned()))
307 .collect();
308 let paths =
309 paths.map_err(|e| color_eyre::eyre::eyre!("Failed to read stdin: {}", e))?;
310 if paths.is_empty() {
311 return Err(color_eyre::eyre::eyre!(
312 "No paths provided — pass paths as arguments or pipe from stdin"
313 ));
314 }
315 Ok(paths)
316 }
317 None => Err(color_eyre::eyre::eyre!(
318 "No paths provided — pass paths as arguments or pipe from stdin"
319 )),
320 }
321}
322
323async fn run_show(
324 vault: &NoteVault,
325 path_inputs: &[String],
326 quick_note_path: &str,
327 format: crate::cli::output::OutputFormat,
328 workspace_name: &str,
329) -> Result<()> {
330 use crate::cli::helpers::resolve_note_path;
331 use crate::cli::json_output::{
332 JsonNoteEntry, JsonNoteMetadata, JsonOutput, JsonOutputMetadata,
333 };
334 use crate::cli::metadata_extractor::{extract_headers, extract_links, extract_tags};
335 use crate::cli::output::OutputFormat;
336 use chrono::Utc;
337 use kimun_core::nfs::NoteEntryData;
338 use std::time::UNIX_EPOCH;
339
340 if matches!(format, OutputFormat::Paths) {
341 return Err(color_eyre::eyre::eyre!(
342 "--format paths is not valid for note show; use 'text' or 'json'"
343 ));
344 }
345
346 enum Accumulator {
348 Text(Vec<String>),
349 Json(Vec<JsonNoteEntry>),
350 }
351
352 let mut acc = match format {
353 OutputFormat::Text => Accumulator::Text(Vec::new()),
354 OutputFormat::Json => Accumulator::Json(Vec::new()),
355 OutputFormat::Paths => unreachable!("guarded above"),
356 };
357 let mut had_errors = false;
358
359 for input in path_inputs {
360 let vault_path = match resolve_note_path(input, quick_note_path) {
361 Ok(p) => p,
362 Err(e) => {
363 eprintln!("Error: {}", e);
364 had_errors = true;
365 continue;
366 }
367 };
368
369 let note_details = match vault.load_note(&vault_path).await {
370 Ok(nd) => nd,
371 Err(e) if e.is_not_found() => {
375 eprintln!(
376 "Error: {}",
377 e.user_message().unwrap_or_else(|| e.to_string())
378 );
379 had_errors = true;
380 continue;
381 }
382 Err(e) => return Err(color_eyre::eyre::eyre!("{}", e)),
383 };
384
385 let content = ¬e_details.raw_text;
386 let content_data = note_details.get_content_data();
387
388 let backlink_results = vault
389 .get_backlinks(&vault_path)
390 .await
391 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
392 let backlink_paths: Vec<String> = backlink_results
393 .iter()
394 .map(|(e, _)| e.path.to_string())
395 .collect();
396
397 match &mut acc {
398 Accumulator::Text(entries) => {
399 let tags = extract_tags(content);
400 let links = extract_links(content);
401 entries.push(format_note_show_text(
402 &vault_path,
403 content,
404 &content_data.title,
405 &tags,
406 &links,
407 &backlink_paths,
408 ));
409 }
410 Accumulator::Json(entries) => {
411 let meta = tokio::fs::metadata(vault.path_to_pathbuf(&vault_path))
412 .await
413 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
414 let modified_secs = meta
415 .modified()
416 .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs())
417 .unwrap_or(0);
418 let entry_data = NoteEntryData {
419 path: vault_path.clone(),
420 size: meta.len(),
421 modified_secs,
422 };
423 let tags = extract_tags(content);
424 let links = extract_links(content);
425 let headers = extract_headers(content);
426 let journal_date = vault
427 .journal_date(&vault_path)
428 .map(|d| d.format("%Y-%m-%d").to_string());
429 entries.push(JsonNoteEntry {
430 path: vault_path.to_string_with_ext(),
431 title: content_data.title.clone(),
432 content: content.clone(),
433 size: entry_data.size,
434 modified: entry_data.modified_secs,
435 created: entry_data.modified_secs, hash: format!("{:x}", content_data.hash),
437 journal_date,
438 metadata: JsonNoteMetadata {
439 tags,
440 links,
441 headers,
442 },
443 backlinks: if backlink_paths.is_empty() {
444 None
445 } else {
446 Some(backlink_paths)
447 },
448 });
449 }
450 }
451 }
452
453 let is_empty = match &acc {
454 Accumulator::Text(v) => v.is_empty(),
455 Accumulator::Json(v) => v.is_empty(),
456 };
457 if is_empty {
458 return Err(color_eyre::eyre::eyre!(
459 "No notes found — all specified paths were missing"
460 ));
461 }
462
463 match acc {
467 Accumulator::Text(entries) => {
468 let sep = format!("\n{}\n\n", NOTE_SEPARATOR);
469 print!("{}", entries.join(&sep));
470 }
471 Accumulator::Json(notes) => {
472 let output = JsonOutput {
473 metadata: JsonOutputMetadata {
474 workspace: workspace_name.to_string(),
475 workspace_path: vault.workspace_path().to_string_lossy().to_string(),
476 total_results: notes.len(),
477 query: None,
478 is_listing: false,
479 generated_at: Utc::now().to_rfc3339(),
480 },
481 notes,
482 };
483 print!(
484 "{}",
485 serde_json::to_string(&output).map_err(|e| color_eyre::eyre::eyre!("{}", e))?
486 );
487 }
488 }
489
490 if had_errors {
491 return Err(color_eyre::eyre::eyre!(
492 "One or more notes could not be found"
493 ));
494 }
495
496 Ok(())
497}
498
499async fn run_triage(vault: &NoteVault) -> Result<()> {
500 let inbox_notes = vault
501 .get_notes(vault.inbox_path(), false)
502 .await
503 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
504
505 if inbox_notes.is_empty() {
506 println!("Inbox is empty.");
507 return Ok(());
508 }
509
510 println!("Inbox notes ({}):\n", inbox_notes.len());
511 for (entry, content_data) in &inbox_notes {
512 let title = if content_data.title.trim().is_empty() {
513 "<no title>"
514 } else {
515 &content_data.title
516 };
517 println!(" {} — {}", entry.path, title);
518 }
519
520 Ok(())
521}
522
523async fn run_quick(vault: &NoteVault, content: Option<String>) -> Result<()> {
524 use crate::cli::helpers::resolve_content;
525
526 let text = resolve_content(content)?;
527 if text.is_empty() {
528 return Ok(());
529 }
530
531 let details = vault
532 .quick_note(&text)
533 .await
534 .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
535
536 println!("Note saved: {}", details.path);
537 Ok(())
538}
539
540#[cfg(test)]
541mod tests {
542 use super::resolve_show_paths;
543 use std::io::Cursor;
544
545 #[test]
546 fn test_resolve_show_paths_uses_args_when_given() {
547 let args = vec!["projects/foo".to_string(), "inbox/bar".to_string()];
548 let result = resolve_show_paths(args.clone(), None::<Cursor<&[u8]>>).unwrap();
549 assert_eq!(result, args);
550 }
551
552 #[test]
553 fn test_resolve_show_paths_reads_from_reader() {
554 let input = b"projects/foo\ninbox/bar\n";
555 let reader = Cursor::new(input.as_ref());
556 let result = resolve_show_paths(vec![], Some(reader)).unwrap();
557 assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
558 }
559
560 #[test]
561 fn test_resolve_show_paths_skips_blank_lines() {
562 let input = b"projects/foo\n\n \ninbox/bar\n";
563 let reader = Cursor::new(input.as_ref());
564 let result = resolve_show_paths(vec![], Some(reader)).unwrap();
565 assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
566 }
567
568 #[test]
569 fn test_resolve_show_paths_all_blank_stdin_returns_empty() {
570 let input = b"\n \n\t\n";
571 let reader = Cursor::new(input.as_ref());
572 let result = resolve_show_paths(vec![], Some(reader));
573 assert!(result.is_err());
574 let msg = result.unwrap_err().to_string();
575 assert!(msg.contains("No paths provided"), "got: {}", msg);
576 }
577
578 #[test]
579 fn test_resolve_show_paths_strips_tab_separated_fields() {
580 let input = b"projects/foo\tFoo Note\t1234\t1700000000\ninbox/bar\tBar\t42\t1700000001\n";
582 let reader = Cursor::new(input.as_ref());
583 let result = resolve_show_paths(vec![], Some(reader)).unwrap();
584 assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
585 }
586
587 #[test]
588 fn test_resolve_show_paths_no_args_no_reader_errors() {
589 let result = resolve_show_paths(vec![], None::<Cursor<&[u8]>>);
590 assert!(result.is_err());
591 let msg = result.unwrap_err().to_string();
592 assert!(msg.contains("No paths provided"), "got: {}", msg);
593 }
594}