1mod git;
8mod git_env;
9
10pub use git::{derive_git_notebook_name, git_rev_parse};
11pub use git_env::{leaked_git_names, scrub_git_env, scrub_git_env_std};
12
13use std::{collections::VecDeque, path::PathBuf, process::Stdio, sync::LazyLock};
14
15use regex::Regex;
16use serde::Deserialize;
17use tokio::process::Command;
18
19static ANSI_REGEX: LazyLock<Regex> =
26 LazyLock::new(|| Regex::new(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|[ -/]*[0-~])").unwrap());
27
28fn strip_ansi(text: &str) -> String {
30 ANSI_REGEX.replace_all(text, "").into_owned()
31}
32
33#[derive(Debug, thiserror::Error)]
35pub enum NbError {
36 #[error("nb command failed: {0}")]
37 CommandFailed(String),
38
39 #[error(
40 "nb not found in PATH; install via: brew install xwmx/taps/nb (macOS) or see https://github.com/xwmx/nb#installation"
41 )]
42 NotFound,
43
44 #[error("IO error: {0}")]
45 Io(#[from] std::io::Error),
46}
47
48#[derive(Clone, Debug)]
53pub struct Config {
54 pub notebook: Option<String>,
56 pub create_notebook: bool,
58 pub allow_top_level_notes: bool,
60 pub disable_git_signing: bool,
62}
63
64impl Default for Config {
65 fn default() -> Self {
66 Self {
67 notebook: None,
68 create_notebook: true,
69 allow_top_level_notes: false,
70 disable_git_signing: false,
71 }
72 }
73}
74
75#[derive(Clone)]
77pub struct NbClient {
78 default_notebook: Option<String>,
80 create_notebook: bool,
82 disable_git_signing: bool,
84 allow_top_level_notes: bool,
86}
87
88const FOLDER_REQUIRED_MESSAGE: &str = "This server is configured to require `folder` for new notes. Use the `nb.mkdir` tool to create new folders and the `nb.folders` tool to list existing folders.";
89
90const NOTEBOOK_FIELD_MESSAGE: &str = "Invalid `notebook`: use a bare notebook name only. Use `folder` for folder paths and `id`/`selector` for note selectors.";
91
92const FOLDER_FIELD_MESSAGE: &str = "Invalid folder path: use `folder` for folder paths only, not notebook-qualified selectors. To choose a notebook, use the separate `notebook` field.";
93
94#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
96#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
97#[serde(rename_all = "lowercase")]
98pub enum EditMode {
99 #[default]
101 Replace,
102 Append,
104 Prepend,
106}
107
108#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
110#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
111#[serde(rename_all = "lowercase")]
112pub enum SearchMode {
113 #[default]
115 Any,
116 All,
118}
119
120#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
122#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
123#[serde(rename_all = "lowercase")]
124pub enum TaskStatus {
125 Open,
127 Closed,
129}
130
131impl NbClient {
132 pub fn new(config: &Config) -> anyhow::Result<Self> {
138 let default_notebook = config
139 .notebook
140 .as_deref()
141 .map(String::from)
142 .or_else(derive_git_notebook_name);
143 Ok(Self {
144 default_notebook,
145 create_notebook: config.create_notebook,
146 disable_git_signing: config.disable_git_signing,
147 allow_top_level_notes: config.allow_top_level_notes,
148 })
149 }
150
151 fn require_folder_for_new_note(&self, folder: Option<&str>) -> Result<(), NbError> {
152 if self.allow_top_level_notes || folder.is_some_and(|value| !value.trim().is_empty()) {
153 return Ok(());
154 }
155 Err(NbError::CommandFailed(FOLDER_REQUIRED_MESSAGE.to_string()))
156 }
157
158 async fn resolve_target_selector(
159 &self,
160 id: &str,
161 notebook: Option<&str>,
162 ) -> Result<(String, String), NbError> {
163 if let Some((embedded_notebook, path)) = parse_qualified_selector(id)? {
164 let notebook = match notebook {
165 Some(value) => {
166 validate_notebook_name(value)?;
167 if value != embedded_notebook {
168 return Err(NbError::CommandFailed(format!(
169 "ambiguous selector: id targets notebook `{embedded_notebook}`, but notebook field is `{value}`"
170 )));
171 }
172 embedded_notebook.to_string()
173 }
174 _ => embedded_notebook.to_string(),
175 };
176 self.ensure_existing_notebook(¬ebook).await?;
177 return Ok((notebook, format!("{}:{}", embedded_notebook, path)));
178 }
179 let notebook = self.resolve_notebook(notebook).await?;
180 Ok((notebook.clone(), format!("{}:{}", notebook, id)))
181 }
182
183 fn append_notebook_warning(&self, output: String, notebook: &str) -> String {
184 let Some(default_notebook) = self.default_notebook.as_deref() else {
185 return output;
186 };
187 if default_notebook == notebook {
188 return output;
189 }
190 append_warning(
191 output,
192 format!(
193 "Warning: wrote to notebook `{notebook}`, not the project default notebook `{default_notebook}`. If this was unintended, move or delete the note and retry with the correct notebook/folder."
194 ),
195 )
196 }
197
198 fn resolve_notebook_name(&self, notebook: Option<&str>) -> Result<String, NbError> {
200 if let Some(name) = notebook {
201 validate_notebook_name(name)?;
202 return Ok(name.to_string());
203 }
204 if let Some(name) = self.default_notebook.as_deref() {
205 validate_notebook_name(name)?;
206 return Ok(name.to_string());
207 }
208 Err(NbError::CommandFailed(
209 "notebook not configured; set --notebook or NB_MCP_NOTEBOOK".to_string(),
210 ))
211 }
212
213 async fn resolve_notebook(&self, notebook: Option<&str>) -> Result<String, NbError> {
214 let name = self.resolve_notebook_name(notebook)?;
215 self.ensure_notebook(&name).await?;
216 Ok(name)
217 }
218
219 async fn ensure_notebook(&self, notebook: &str) -> Result<(), NbError> {
220 match self.check_notebook(notebook).await {
221 Ok(()) => Ok(()),
222 Err(_) => {
223 if !self.create_notebook {
224 return Err(NbError::CommandFailed(format!(
225 "notebook not found; create it with the nb CLI (`nb notebooks add {}`) \
226 or remove --no-create-notebook",
227 notebook
228 )));
229 }
230 self.exec_vec(vec![
231 "notebooks".to_string(),
232 "add".to_string(),
233 notebook.to_string(),
234 ])
235 .await?;
236 Ok(())
237 }
238 }
239 }
240
241 async fn ensure_existing_notebook(&self, notebook: &str) -> Result<(), NbError> {
242 self.check_notebook(notebook).await.map_err(|_| {
243 NbError::CommandFailed(format!(
244 "notebook not found: `{notebook}`. Use a copied selector only for an existing notebook."
245 ))
246 })
247 }
248
249 async fn check_notebook(&self, notebook: &str) -> Result<(), NbError> {
250 let show_result = self
251 .exec_vec(vec![
252 "notebooks".to_string(),
253 "show".to_string(),
254 notebook.to_string(),
255 "--path".to_string(),
256 ])
257 .await;
258 match show_result {
259 Ok(output) => {
260 if output.trim().is_empty() {
261 return Err(NbError::CommandFailed(
262 "nb notebooks path output was empty".to_string(),
263 ));
264 }
265 Ok(())
266 }
267 Err(_) => Err(NbError::CommandFailed(format!(
268 "notebook not found: `{notebook}`"
269 ))),
270 }
271 }
272
273 async fn exec(&self, args: &[&str]) -> Result<String, NbError> {
275 tracing::debug!(?args, "executing nb command");
276 let mut command = Command::new("nb");
277 scrub_git_env(&mut command);
289 command
290 .args(args)
291 .stdin(Stdio::null()) .stdout(Stdio::piped())
293 .stderr(Stdio::piped());
294 if self.disable_git_signing {
295 apply_git_signing_env(&mut command);
296 }
297 let output = command
298 .spawn()
299 .map_err(|e| {
300 if e.kind() == std::io::ErrorKind::NotFound {
301 NbError::NotFound
302 } else {
303 NbError::Io(e)
304 }
305 })?
306 .wait_with_output()
307 .await?;
308
309 if output.status.success() {
310 let stdout = String::from_utf8_lossy(&output.stdout);
311 Ok(strip_ansi(&stdout))
312 } else {
313 let stderr = String::from_utf8_lossy(&output.stderr);
314 let stdout = String::from_utf8_lossy(&output.stdout);
315 let msg = if stderr.is_empty() {
317 strip_ansi(&stdout)
318 } else {
319 strip_ansi(&stderr)
320 };
321 Err(NbError::CommandFailed(msg))
322 }
323 }
324
325 async fn exec_vec(&self, args: Vec<String>) -> Result<String, NbError> {
327 let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
328 self.exec(&args_ref).await
329 }
330
331 pub async fn status(&self, notebook: Option<&str>) -> Result<String, NbError> {
333 let notebook = self.resolve_notebook(notebook).await?;
334 self.exec_vec(vec![format!("{}:", notebook), "status".to_string()])
335 .await
336 }
337
338 pub async fn notebooks(&self) -> Result<String, NbError> {
340 self.exec(&["notebooks", "--no-color"]).await
342 }
343
344 pub async fn notebook_path(&self, notebook: Option<&str>) -> Result<PathBuf, NbError> {
346 let notebook = self.resolve_notebook(notebook).await?;
347 let output = self
348 .exec_vec(vec![
349 "notebooks".to_string(),
350 "show".to_string(),
351 notebook,
352 "--path".to_string(),
353 ])
354 .await?;
355 let path = output.trim();
356 if path.is_empty() {
357 return Err(NbError::CommandFailed(
358 "nb notebooks path output was empty".to_string(),
359 ));
360 }
361 Ok(PathBuf::from(path))
362 }
363
364 pub async fn add(
366 &self,
367 title: Option<&str>,
368 content: &str,
369 tags: &[String],
370 folder: Option<&str>,
371 notebook: Option<&str>,
372 ) -> Result<String, NbError> {
373 let mut args = Vec::new();
374 self.require_folder_for_new_note(folder)?;
375 validate_folder_option(folder)?;
376
377 let notebook = self.resolve_notebook(notebook).await?;
378 let cmd = format!("{}:add", notebook);
379 args.push(cmd);
380
381 if let Some(t) = title {
383 args.push("--title".to_string());
384 args.push(t.to_string());
385 }
386
387 args.push("--content".to_string());
389 args.push(content.to_string());
390
391 for tag in tags {
393 args.push("--tags".to_string());
394 let tag_str = if tag.starts_with('#') {
395 tag.clone()
396 } else {
397 format!("#{}", tag)
398 };
399 args.push(tag_str);
400 }
401
402 if let Some(f) = folder {
404 args.push("--folder".to_string());
405 args.push(f.to_string());
406 }
407
408 self.exec_vec(args)
409 .await
410 .map(|output| self.append_notebook_warning(output, ¬ebook))
411 }
412
413 pub async fn show(&self, id: &str, notebook: Option<&str>) -> Result<String, NbError> {
415 let (_, selector) = self.resolve_target_selector(id, notebook).await?;
416 self.exec_vec(vec![
423 "show".to_string(),
424 selector,
425 "--print".to_string(),
426 "--no-color".to_string(),
427 ])
428 .await
429 }
430
431 pub async fn list(
433 &self,
434 folder: Option<&str>,
435 tags: &[String],
436 limit: Option<u32>,
437 notebook: Option<&str>,
438 ) -> Result<String, NbError> {
439 let mut args = Vec::new();
440 validate_folder_option(folder)?;
441
442 let notebook = self.resolve_notebook(notebook).await?;
443 let cmd = match folder {
444 Some(f) => format!("{}:{}/", notebook, f),
445 None => format!("{}:", notebook),
446 };
447
448 args.push("list".to_string());
449 args.push(cmd);
450
451 args.push("--no-color".to_string());
453
454 if let Some(n) = limit {
456 args.push("-n".to_string());
457 args.push(n.to_string());
458 }
459
460 for tag in tags {
462 args.push("--tags".to_string());
463 let tag_str = if tag.starts_with('#') {
464 tag.clone()
465 } else {
466 format!("#{}", tag)
467 };
468 args.push(tag_str);
469 }
470
471 self.exec_vec(args).await
472 }
473
474 pub async fn search(
476 &self,
477 queries: &[String],
478 mode: SearchMode,
479 tags: &[String],
480 folder: Option<&str>,
481 notebook: Option<&str>,
482 ) -> Result<String, NbError> {
483 validate_folder_option(folder)?;
484 if queries.is_empty() {
485 return Err(NbError::CommandFailed(
486 "at least one search query is required".to_string(),
487 ));
488 }
489
490 let notebook = self.resolve_notebook(notebook).await?;
491 let scope = match folder {
492 Some(f) => format!("{}:{}/", notebook, f),
493 None => format!("{}:", notebook),
494 };
495 let args = search_command_args(scope, queries, mode, tags);
496 self.exec_vec(args).await
497 }
498
499 pub async fn edit(
501 &self,
502 id: &str,
503 content: &str,
504 mode: EditMode,
505 notebook: Option<&str>,
506 ) -> Result<String, NbError> {
507 let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
508 let output = self.exec_vec(edit_args(selector, content, mode)).await?;
509 Ok(self.append_notebook_warning(output, ¬ebook))
510 }
511
512 pub async fn delete(&self, id: &str, notebook: Option<&str>) -> Result<String, NbError> {
514 let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
515 let output = self
516 .exec_vec(vec!["delete".to_string(), selector, "--force".to_string()])
517 .await?;
518 Ok(self.append_notebook_warning(output, ¬ebook))
519 }
520
521 pub async fn move_note(
523 &self,
524 id: &str,
525 destination: &str,
526 notebook: Option<&str>,
527 ) -> Result<String, NbError> {
528 validate_destination(destination)?;
529 let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
530 let output = self
531 .exec_vec(vec![
532 "move".to_string(),
533 selector,
534 destination.to_string(),
535 "--force".to_string(),
536 ])
537 .await?;
538 Ok(self.append_notebook_warning(output, ¬ebook))
539 }
540
541 pub async fn todo(
543 &self,
544 title: &str,
545 description: Option<&str>,
546 tasks: &[String],
547 tags: &[String],
548 folder: Option<&str>,
549 notebook: Option<&str>,
550 ) -> Result<String, NbError> {
551 self.require_folder_for_new_note(folder)?;
552 validate_folder_option(folder)?;
553 let notebook = self.resolve_notebook(notebook).await?;
554 let output = self
555 .exec_vec(todo_command_args(
556 ¬ebook,
557 title,
558 description,
559 tasks,
560 tags,
561 folder,
562 ))
563 .await?;
564 Ok(self.append_notebook_warning(output, ¬ebook))
565 }
566
567 pub async fn do_task(
569 &self,
570 id: &str,
571 task_number: Option<u32>,
572 notebook: Option<&str>,
573 ) -> Result<String, NbError> {
574 let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
575 let output = self
576 .exec_vec(task_command_args("do", selector, task_number))
577 .await?;
578 Ok(self.append_notebook_warning(output, ¬ebook))
579 }
580
581 pub async fn undo_task(
583 &self,
584 id: &str,
585 task_number: Option<u32>,
586 notebook: Option<&str>,
587 ) -> Result<String, NbError> {
588 let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
589 let output = self
590 .exec_vec(task_command_args("undo", selector, task_number))
591 .await?;
592 Ok(self.append_notebook_warning(output, ¬ebook))
593 }
594
595 pub async fn tasks(
597 &self,
598 folder: Option<&str>,
599 status: Option<TaskStatus>,
600 recursive: bool,
601 notebook: Option<&str>,
602 ) -> Result<String, NbError> {
603 validate_folder_option(folder)?;
604 let notebook = self.resolve_notebook(notebook).await?;
605 let folder = folder.map(normalize_folder);
606 let scopes = if recursive {
607 self.tasks_scopes_recursive(¬ebook, folder.as_deref())
608 .await?
609 } else {
610 vec![tasks_scope(¬ebook, folder.as_deref())]
611 };
612
613 let mut outputs: Vec<String> = Vec::new();
614 let mut saw_empty = false;
615 for scope in scopes {
616 match self.exec_vec(tasks_command_args(scope, status)).await {
617 Ok(output) => {
618 let output = output.trim();
619 if !output.is_empty() {
620 outputs.push(output.to_string());
621 }
622 }
623 Err(NbError::CommandFailed(message)) if is_empty_tasks_error(&message) => {
624 saw_empty = true;
625 }
626 Err(err) => return Err(err),
627 }
628 }
629 if outputs.is_empty() && saw_empty {
630 return Err(NbError::CommandFailed(empty_tasks_message(status)));
631 }
632 Ok(outputs.join("\n"))
633 }
634
635 async fn tasks_scopes_recursive(
636 &self,
637 notebook: &str,
638 folder: Option<&str>,
639 ) -> Result<Vec<String>, NbError> {
640 let notebook_root = self.notebook_path(Some(notebook)).await?;
641 let start = folder.unwrap_or_default().to_string();
642 let mut queue = VecDeque::new();
643 queue.push_back(start.clone());
644
645 let mut scopes = vec![tasks_scope(notebook, folder)];
646 while let Some(current) = queue.pop_front() {
647 let base = if current.is_empty() {
648 notebook_root.clone()
649 } else {
650 notebook_root.join(¤t)
651 };
652 let children = child_folder_names(&base)?;
653 for child in children {
654 let next = if current.is_empty() {
655 child
656 } else {
657 format!("{}/{}", current, child)
658 };
659 scopes.push(tasks_scope(notebook, Some(&next)));
660 queue.push_back(next);
661 }
662 }
663 Ok(scopes)
664 }
665
666 pub async fn bookmark(
668 &self,
669 url: &str,
670 title: Option<&str>,
671 tags: &[String],
672 comment: Option<&str>,
673 folder: Option<&str>,
674 notebook: Option<&str>,
675 ) -> Result<String, NbError> {
676 let mut args = Vec::new();
677 self.require_folder_for_new_note(folder)?;
678 validate_folder_option(folder)?;
679
680 let notebook = self.resolve_notebook(notebook).await?;
682 let dest = match folder {
683 Some(f) => format!("{}:{}/", notebook, f),
684 None => format!("{}:", notebook),
685 };
686
687 let cmd = format!("{}bookmark", dest);
688 args.push(cmd);
689 args.push(url.to_string());
690
691 if let Some(t) = title {
692 args.push("--title".to_string());
693 args.push(t.to_string());
694 }
695
696 if let Some(c) = comment {
697 args.push("--comment".to_string());
698 args.push(c.to_string());
699 }
700
701 for tag in tags {
702 args.push("--tags".to_string());
703 let tag_str = if tag.starts_with('#') {
704 tag.clone()
705 } else {
706 format!("#{}", tag)
707 };
708 args.push(tag_str);
709 }
710
711 self.exec_vec(args)
712 .await
713 .map(|output| self.append_notebook_warning(output, ¬ebook))
714 }
715
716 pub async fn folders(
718 &self,
719 parent: Option<&str>,
720 notebook: Option<&str>,
721 ) -> Result<String, NbError> {
722 let mut args = vec!["list".to_string()];
723 validate_folder_option(parent)?;
724
725 let notebook = self.resolve_notebook(notebook).await?;
726 let path = match parent {
727 Some(p) => format!("{}:{}/", notebook, p),
728 None => format!("{}:", notebook),
729 };
730 args.push(path);
731
732 args.push("--type".to_string());
734 args.push("folder".to_string());
735 args.push("--no-color".to_string());
736
737 self.exec_vec(args).await
738 }
739
740 pub async fn mkdir(&self, path: &str, notebook: Option<&str>) -> Result<String, NbError> {
742 validate_folder_path(path)?;
743 let notebook = self.resolve_notebook(notebook).await?;
744 let folder_path = mkdir_selector(¬ebook, path);
745 let output = self
746 .exec_vec(vec!["add".to_string(), "folder".to_string(), folder_path])
747 .await?;
748 Ok(self.append_notebook_warning(output, ¬ebook))
749 }
750
751 pub async fn import(
753 &self,
754 source: &str,
755 folder: Option<&str>,
756 filename: Option<&str>,
757 convert: bool,
758 notebook: Option<&str>,
759 ) -> Result<String, NbError> {
760 let mut args = Vec::new();
761 self.require_folder_for_new_note(folder)?;
762 validate_folder_option(folder)?;
763
764 let notebook = self.resolve_notebook(notebook).await?;
765 let cmd = format!("{}:import", notebook);
766 args.push(cmd);
767
768 args.push(source.to_string());
770
771 if convert {
773 args.push("--convert".to_string());
774 }
775
776 if folder.is_some() || filename.is_some() {
779 let dest = match (folder, filename) {
780 (Some(f), Some(n)) => format!("{}/{}", f, n),
781 (Some(f), None) => format!("{}/", f),
782 (None, Some(n)) => n.to_string(),
783 (None, None) => unreachable!(),
784 };
785 args.push(dest);
786 }
787
788 self.exec_vec(args)
789 .await
790 .map(|output| self.append_notebook_warning(output, ¬ebook))
791 }
792}
793
794fn append_warning(mut output: String, warning: String) -> String {
795 if !output.trim().is_empty() {
796 if !output.ends_with('\n') {
797 output.push('\n');
798 }
799 output.push('\n');
800 }
801 output.push_str(&warning);
802 output
803}
804
805fn validate_notebook_name(name: &str) -> Result<(), NbError> {
806 if name.trim().is_empty() || name.contains(':') || name.contains('/') || name.contains('\\') {
807 return Err(NbError::CommandFailed(NOTEBOOK_FIELD_MESSAGE.to_string()));
808 }
809 Ok(())
810}
811
812fn validate_folder_option(folder: Option<&str>) -> Result<(), NbError> {
813 if let Some(path) = folder {
814 validate_folder_path(path)?;
815 }
816 Ok(())
817}
818
819fn validate_folder_path(path: &str) -> Result<(), NbError> {
820 if path.trim().is_empty() || path.contains(':') {
821 return Err(NbError::CommandFailed(FOLDER_FIELD_MESSAGE.to_string()));
822 }
823 Ok(())
824}
825
826fn validate_destination(destination: &str) -> Result<(), NbError> {
827 if destination.trim().is_empty() || destination.contains(':') {
828 return Err(NbError::CommandFailed(
829 "Invalid destination: use a folder path or filename only, not a notebook-qualified selector."
830 .to_string(),
831 ));
832 }
833 Ok(())
834}
835
836fn parse_qualified_selector(selector: &str) -> Result<Option<(&str, &str)>, NbError> {
837 let Some((notebook, path)) = selector.split_once(':') else {
838 return Ok(None);
839 };
840 validate_notebook_name(notebook)?;
841 if path.trim().is_empty() || path.contains(':') {
842 return Err(NbError::CommandFailed(
843 "Invalid selector: use at most one notebook qualifier, as `<notebook>:<folder>/<id>`."
844 .to_string(),
845 ));
846 }
847 Ok(Some((notebook, path)))
848}
849
850fn edit_args(selector: String, content: &str, mode: EditMode) -> Vec<String> {
851 let mut args = vec!["edit".to_string(), selector];
852 match mode {
853 EditMode::Replace => args.push("--overwrite".to_string()),
854 EditMode::Append => {}
855 EditMode::Prepend => args.push("--prepend".to_string()),
856 }
857 args.push("--content".to_string());
858 args.push(content.to_string());
859 args
860}
861
862fn task_command_args(action: &str, selector: String, task_number: Option<u32>) -> Vec<String> {
863 let mut args = vec![action.to_string(), selector];
864 if let Some(number) = task_number {
865 args.push(number.to_string());
866 }
867 args
868}
869
870fn todo_command_args(
871 notebook: &str,
872 title: &str,
873 description: Option<&str>,
874 tasks: &[String],
875 tags: &[String],
876 folder: Option<&str>,
877) -> Vec<String> {
878 let mut args = vec![format!("{notebook}:todo"), "add".to_string()];
879
880 if let Some(folder) = folder {
882 args.push(folder_scope(folder));
883 }
884
885 args.push(title.to_string());
886
887 if let Some(description) = description {
888 args.push("--description".to_string());
889 args.push(description.to_string());
890 }
891
892 for task in tasks {
893 args.push("--task".to_string());
894 args.push(task.to_string());
895 }
896
897 for tag in tags {
898 args.push("--tags".to_string());
899 args.push(normalize_tag(tag));
900 }
901
902 args
903}
904
905fn folder_scope(folder: &str) -> String {
906 if folder.ends_with('/') {
907 folder.to_string()
908 } else {
909 format!("{folder}/")
910 }
911}
912
913fn normalize_tag(tag: &str) -> String {
914 if tag.starts_with('#') {
915 tag.to_string()
916 } else {
917 format!("#{tag}")
918 }
919}
920
921fn normalize_folder(folder: &str) -> String {
922 folder.trim_matches('/').to_string()
923}
924
925fn mkdir_selector(notebook: &str, path: &str) -> String {
926 let normalized = normalize_folder(path);
927 format!("{}:{}", notebook, normalized)
928}
929
930fn tasks_scope(notebook: &str, folder: Option<&str>) -> String {
931 match folder {
932 Some(path) if !path.is_empty() => format!("{}:{}/", notebook, path),
933 _ => format!("{}:", notebook),
934 }
935}
936
937fn tasks_command_args(scope: String, status: Option<TaskStatus>) -> Vec<String> {
938 let mut args = vec!["tasks".to_string(), scope];
939 if let Some(filter) = status {
940 let status = match filter {
941 TaskStatus::Open => "open",
942 TaskStatus::Closed => "closed",
943 };
944 args.push(status.to_string());
945 }
946 args.push("--no-color".to_string());
947 args
948}
949
950fn search_command_args(
951 scope: String,
952 queries: &[String],
953 mode: SearchMode,
954 tags: &[String],
955) -> Vec<String> {
956 let mut args = vec!["search".to_string(), scope];
957 let mut terms = queries.iter();
958 if let Some(first) = terms.next() {
959 args.push(first.to_string());
960 }
961 match mode {
962 SearchMode::Any => {
963 for query in terms {
964 args.push("--or".to_string());
965 args.push(query.to_string());
966 }
967 }
968 SearchMode::All => {
969 for query in terms {
970 args.push(query.to_string());
971 }
972 }
973 }
974 for tag in tags {
975 args.push("--tag".to_string());
976 args.push(normalize_tag(tag));
977 }
978 args.push("--no-color".to_string());
979 args
980}
981
982fn is_empty_tasks_error(message: &str) -> bool {
983 message.trim_start().starts_with("! 0 ") && message.contains(" tasks.")
984}
985
986fn empty_tasks_message(status: Option<TaskStatus>) -> String {
987 match status {
988 Some(TaskStatus::Open) => "! 0 open tasks.".to_string(),
989 Some(TaskStatus::Closed) => "! 0 closed tasks.".to_string(),
990 None => "! 0 tasks.".to_string(),
991 }
992}
993
994fn child_folder_names(path: &std::path::Path) -> Result<Vec<String>, NbError> {
995 let read_dir = match std::fs::read_dir(path) {
996 Ok(entries) => entries,
997 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
998 Err(err) => return Err(NbError::Io(err)),
999 };
1000
1001 let mut names = Vec::new();
1002 for entry in read_dir {
1003 let entry = entry?;
1004 let Some(name) = entry.file_name().to_str().map(|value| value.to_string()) else {
1005 continue;
1006 };
1007 if name.starts_with('.') {
1008 continue;
1009 }
1010 let meta = match entry.metadata() {
1011 Ok(meta) => meta,
1012 Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
1013 Err(err) => return Err(NbError::Io(err)),
1014 };
1015 if meta.is_dir() {
1016 names.push(name);
1017 }
1018 }
1019 names.sort();
1020 Ok(names)
1021}
1022
1023const GIT_SIGNING_OVERRIDES: [(&str, &str); 2] =
1024 [("commit.gpgsign", "false"), ("tag.gpgsign", "false")];
1025
1026fn git_config_count(raw: Option<&str>) -> usize {
1027 raw.and_then(|value| value.parse::<usize>().ok())
1028 .unwrap_or(0)
1029}
1030
1031fn git_signing_env_vars(start_index: usize) -> Vec<(String, String)> {
1032 let total = start_index.saturating_add(GIT_SIGNING_OVERRIDES.len());
1033 let mut env_vars = Vec::with_capacity(1 + GIT_SIGNING_OVERRIDES.len() * 2);
1034 env_vars.push(("GIT_CONFIG_COUNT".to_string(), total.to_string()));
1035 for (offset, (key, value)) in GIT_SIGNING_OVERRIDES.iter().enumerate() {
1036 let index = start_index + offset;
1037 env_vars.push((format!("GIT_CONFIG_KEY_{index}"), (*key).to_string()));
1038 env_vars.push((format!("GIT_CONFIG_VALUE_{index}"), (*value).to_string()));
1039 }
1040 env_vars
1041}
1042
1043fn apply_git_signing_env(command: &mut Command) {
1044 let start_index = git_config_count(std::env::var("GIT_CONFIG_COUNT").ok().as_deref());
1045 for (name, value) in git_signing_env_vars(start_index) {
1046 command.env(name, value);
1047 }
1048}