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