Skip to main content

nb_api/
lib.rs

1//! Typed Rust interface to the `nb` note-taking CLI.
2//!
3//! Handles notebook qualification, escaping, and output parsing.
4//! Wraps the `nb` CLI as a subprocess, providing async methods for
5//! all note-taking operations.
6
7mod 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
17/// Regex to match ANSI/ISO 2022 escape sequences.
18///
19/// Covers:
20/// - Fe sequences: `ESC [@-Z\-_]` (single byte after ESC)
21/// - CSI sequences: `ESC [ ... m` (SGR colors, cursor control, etc.)
22/// - nF sequences: `ESC [ -/]* [0-~]` (character set designation like `ESC ( B`)
23static ANSI_REGEX: LazyLock<Regex> =
24    LazyLock::new(|| Regex::new(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|[ -/]*[0-~])").unwrap());
25
26/// Strip ANSI escape sequences from text.
27fn strip_ansi(text: &str) -> String {
28    ANSI_REGEX.replace_all(text, "").into_owned()
29}
30
31/// Errors from nb CLI invocation.
32#[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/// Configuration for constructing an [`NbClient`].
47///
48/// Contains only nb-relevant fields. MCP-specific fields
49/// (e.g., `show_paths`) remain in the server's config.
50#[derive(Clone, Debug)]
51pub struct Config {
52    /// Default notebook name (overrides Git-derived fallback).
53    pub notebook: Option<String>,
54    /// Automatically create missing notebooks.
55    pub create_notebook: bool,
56    /// Allow new notes to be created at notebook root.
57    pub allow_top_level_notes: bool,
58    /// Disable Git commit and tag signing for `nb` subprocesses.
59    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/// Client for invoking nb commands.
74#[derive(Clone)]
75pub struct NbClient {
76    /// Default notebook to use if not specified per-command.
77    default_notebook: Option<String>,
78    /// Automatically create missing notebooks.
79    create_notebook: bool,
80    /// Disable Git commit and tag signing for `nb` subprocesses.
81    disable_git_signing: bool,
82    /// Allow new notes to be created at notebook root.
83    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/// Behavior mode for `nb edit` content updates.
93#[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    /// Replace note content using `nb edit --overwrite`.
98    #[default]
99    Replace,
100    /// Append content using `nb edit --content` (nb default behavior).
101    Append,
102    /// Prepend content using `nb edit --prepend`.
103    Prepend,
104}
105
106/// Matching mode for `nb search` query terms.
107#[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    /// Match any query term (`OR` semantics).
112    #[default]
113    Any,
114    /// Require all query terms (`AND` semantics).
115    All,
116}
117
118/// Status filter for `nb tasks`.
119#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
120#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
121#[serde(rename_all = "lowercase")]
122pub enum TaskStatus {
123    /// Return open tasks.
124    Open,
125    /// Return closed tasks.
126    Closed,
127}
128
129impl NbClient {
130    /// Creates a new nb client.
131    ///
132    /// Uses the notebook from config if set, otherwise falls back to a
133    /// Git-derived notebook name. Does NOT read `NB_MCP_NOTEBOOK` —
134    /// that is an MCP-server-specific env var resolved by the server.
135    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(&notebook).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    /// Resolves the notebook to use for a command.
197    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    /// Executes an nb command and returns stdout.
272    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()) // Prevent TTY hangs
278            .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            // nb sometimes writes errors to stdout
302            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    /// Executes an nb command with dynamic arguments.
312    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    /// Returns status information about the resolved notebook.
318    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    /// Lists available notebooks.
325    pub async fn notebooks(&self) -> Result<String, NbError> {
326        // Use --no-color to avoid ANSI escape codes
327        self.exec(&["notebooks", "--no-color"]).await
328    }
329
330    /// Returns the path for a notebook.
331    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    /// Creates a new note.
351    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        // Title (if provided)
368        if let Some(t) = title {
369            args.push("--title".to_string());
370            args.push(t.to_string());
371        }
372
373        // Content via --content flag (avoids shell escaping issues)
374        args.push("--content".to_string());
375        args.push(content.to_string());
376
377        // Tags (nb expects #hashtag format)
378        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        // Folder
389        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, &notebook))
397    }
398
399    /// Shows a note's content.
400    pub async fn show(&self, id: &str, notebook: Option<&str>) -> Result<String, NbError> {
401        let (_, selector) = self.resolve_target_selector(id, notebook).await?;
402        // Pass `--print` so `nb show` writes stored bytes to stdout instead of
403        // piping through the renderer/pager. The renderer path word-wraps at
404        // ~80 columns when stdout is a pipe, silently corrupting any stored
405        // line longer than that (e.g. JSON in change-meta notes, code blocks,
406        // long URLs). `--print` returns the file verbatim. Do not remove.
407        // See `nb-api:issues/2`.
408        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    /// Lists notes in a notebook or folder.
418    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        // No color for parsing
438        args.push("--no-color".to_string());
439
440        // Limit
441        if let Some(n) = limit {
442            args.push("-n".to_string());
443            args.push(n.to_string());
444        }
445
446        // Tags filter
447        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    /// Searches notes.
461    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    /// Edits a note using the provided content mode.
486    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, &notebook))
496    }
497
498    /// Deletes a note.
499    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, &notebook))
505    }
506
507    /// Moves or renames a note.
508    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, &notebook))
525    }
526
527    /// Creates a todo item.
528    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                &notebook,
543                title,
544                description,
545                tasks,
546                tags,
547                folder,
548            ))
549            .await?;
550        Ok(self.append_notebook_warning(output, &notebook))
551    }
552
553    /// Marks a todo as done.
554    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, &notebook))
565    }
566
567    /// Marks a todo as not done.
568    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, &notebook))
579    }
580
581    /// Lists todos.
582    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(&notebook, folder.as_deref())
594                .await?
595        } else {
596            vec![tasks_scope(&notebook, 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(&current)
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    /// Creates a bookmark.
653    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        // Build the destination path with optional folder
667        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, &notebook))
700    }
701
702    /// Lists folders in a notebook.
703    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        // Filter to only show folders
719        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    /// Creates a folder.
727    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(&notebook, 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, &notebook))
735    }
736
737    /// Imports a file or URL into the notebook.
738    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        // Source path or URL
755        args.push(source.to_string());
756
757        // Convert HTML to Markdown
758        if convert {
759            args.push("--convert".to_string());
760        }
761
762        // Destination: notebook:folder/filename or just folder/filename
763        // nb import expects destination as a positional argument after source
764        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, &notebook))
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    // Folder path comes as a positional argument before the title.
867    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}