Skip to main content

emperor_folder/
folder.rs

1//! The root folders and the seven operations on them.
2//!
3//! [`Folder`] holds each root opened as a [`cap_std::fs::Dir`]. Every file operation goes
4//! through one of those handles with a root-relative path, so the operating system (via cap-std)
5//! refuses anything that would resolve outside that root: absolute paths, `..`, and symlinks
6//! pointing out, including symlinks from one root into another. Listing and search walk with the
7//! [`ignore`] crate (ripgrep's walker, `.gitignore` and hidden-file aware, symlinks not
8//! followed); each path the walker yields is made relative to its root and re-opened through the
9//! `Dir`, never by its absolute path, so an entry swapped for an escaping symlink mid-walk is
10//! skipped rather than read.
11//!
12//! With one root, tool paths are plain root-relative paths. With several, a tool path starts
13//! with the root's name (`docs/guide.md`), and `.` is a virtual folder holding the roots.
14//!
15//! The operations are synchronous; the MCP layer runs them on the blocking pool.
16
17use std::io::{BufRead, BufReader, Read, Write};
18use std::path::{Component, Path, PathBuf};
19use std::str::FromStr;
20use std::time::UNIX_EPOCH;
21
22use cap_std::ambient_authority;
23use cap_std::fs::{Dir, Metadata, OpenOptions};
24use emperor_mcp::limits::{self, Limits};
25use globset::{GlobBuilder, GlobMatcher};
26use ignore::WalkBuilder;
27use regex::RegexBuilder;
28use rmcp::schemars;
29use serde::{Deserialize, Serialize};
30use serde_json::{Value, json};
31
32use crate::error::FolderError;
33use crate::path;
34
35/// Default for [`Config::max_file_bytes`]: 1 MiB.
36pub const DEFAULT_MAX_FILE_BYTES: u64 = 1024 * 1024;
37/// Default for [`Config::max_results`].
38pub const DEFAULT_MAX_RESULTS: usize = 200;
39/// Default for [`Config::max_chars`]: emperor-mcp's default output cap.
40pub const DEFAULT_MAX_CHARS: usize = limits::DEFAULT_MAX_CHARS;
41/// Largest image `read` returns as image content: 5 MiB.
42pub const MAX_IMAGE_BYTES: u64 = 5 * 1024 * 1024;
43/// Bytes inspected for a NUL byte to decide a file is binary (ripgrep's heuristic).
44const BINARY_SNIFF_BYTES: usize = 8 * 1024;
45/// Longest matched line returned by `search`, in characters.
46const MAX_MATCH_LINE_CHARS: usize = 240;
47/// Compiled-regex size cap, so a pathological pattern cannot eat memory.
48const REGEX_SIZE_LIMIT: usize = 1024 * 1024;
49
50/// One root folder as the operator named it: `DIR` or `NAME=DIR`.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct RootSpec {
53    /// The name paths start with when there are several roots; `None` uses the folder's
54    /// basename.
55    pub name: Option<String>,
56    /// The folder.
57    pub path: PathBuf,
58}
59
60impl RootSpec {
61    /// An unnamed root (named after its folder).
62    pub fn new(path: impl Into<PathBuf>) -> Self {
63        Self {
64            name: None,
65            path: path.into(),
66        }
67    }
68
69    /// A named root.
70    pub fn named(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
71        Self {
72            name: Some(name.into()),
73            path: path.into(),
74        }
75    }
76}
77
78impl FromStr for RootSpec {
79    type Err = String;
80
81    /// `DIR`, or `NAME=DIR` when the part before the first `=` contains no path separator.
82    fn from_str(s: &str) -> Result<Self, Self::Err> {
83        if s.is_empty() {
84            return Err("an empty root".into());
85        }
86        if let Some((name, dir)) = s.split_once('=')
87            && !name.is_empty()
88            && !name.contains(['/', '\\'])
89        {
90            if dir.is_empty() {
91                return Err(format!("root {name}= names no folder"));
92            }
93            return Ok(Self::named(name, dir));
94        }
95        Ok(Self::new(s))
96    }
97}
98
99/// What the operator configured at start-up.
100#[derive(Debug, Clone)]
101pub struct Config {
102    /// The folders the server is confined to (at least one).
103    pub roots: Vec<RootSpec>,
104    /// Allow `write`, `edit` and `move`. Off by default.
105    pub writable: bool,
106    /// Include hidden files and directories in `list` and `search`.
107    pub hidden: bool,
108    /// Ignore `.gitignore`, `.ignore` and git exclude files in `list` and `search`.
109    pub no_ignore: bool,
110    /// Largest file `read`, `search`, `stat` and `edit` will open, and largest `write`.
111    pub max_file_bytes: u64,
112    /// Most entries `list` and matches `search` return.
113    pub max_results: usize,
114    /// Most characters of text one `read` returns.
115    pub max_chars: usize,
116}
117
118impl Config {
119    /// Read-only defaults for one unnamed `root`.
120    pub fn new(root: impl Into<PathBuf>) -> Self {
121        Self::with_roots(vec![RootSpec::new(root)])
122    }
123
124    /// Read-only defaults for several roots.
125    pub fn with_roots(roots: Vec<RootSpec>) -> Self {
126        Self {
127            roots,
128            writable: false,
129            hidden: false,
130            no_ignore: false,
131            max_file_bytes: DEFAULT_MAX_FILE_BYTES,
132            max_results: DEFAULT_MAX_RESULTS,
133            max_chars: DEFAULT_MAX_CHARS,
134        }
135    }
136
137    /// Add a named root.
138    pub fn root(mut self, name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
139        self.roots.push(RootSpec::named(name, path));
140        self
141    }
142
143    /// Allow or forbid `write`, `edit` and `move`.
144    pub fn writable(mut self, writable: bool) -> Self {
145        self.writable = writable;
146        self
147    }
148
149    /// Set the character cap of `read`, clamped to emperor-mcp's ceiling.
150    pub fn max_chars(mut self, n: usize) -> Self {
151        self.max_chars = Limits::default().with_max_chars(n).max_chars();
152        self
153    }
154}
155
156/// How `write` treats an existing file.
157#[derive(
158    Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema,
159)]
160#[serde(rename_all = "lowercase")]
161pub enum WriteMode {
162    /// Create a new file; refuse if it exists.
163    #[default]
164    Create,
165    /// Replace the file's content, creating it if missing.
166    Overwrite,
167    /// Add to the end of the file, creating it if missing.
168    Append,
169}
170
171/// Result of `read`.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub enum ReadOutput {
174    /// Text content.
175    Text {
176        /// Path as the tools address it.
177        path: String,
178        /// The lines read, newlines kept.
179        content: String,
180        /// First line returned (1-based); 0 when nothing was returned.
181        first_line: usize,
182        /// Last line returned (1-based); 0 when nothing was returned.
183        last_line: usize,
184        /// Total lines in the file, when the read reached the end.
185        total_lines: Option<usize>,
186        /// The byte or character limit cut the content short.
187        truncated: bool,
188        /// The cut fell inside `last_line` (it is incomplete).
189        partial_last_line: bool,
190    },
191    /// A small PNG, JPEG, GIF or WebP image.
192    Image {
193        /// Path as the tools address it.
194        path: String,
195        /// `image/png`, `image/jpeg`, `image/gif` or `image/webp`.
196        mime: &'static str,
197        /// The file's bytes.
198        data: Vec<u8>,
199    },
200    /// A binary file: reported, not dumped.
201    Binary {
202        /// Path as the tools address it.
203        path: String,
204        /// Size in bytes.
205        size: u64,
206    },
207}
208
209/// One opened root.
210#[derive(Debug)]
211struct Root {
212    name: String,
213    dir: Dir,
214    path: PathBuf,
215    /// Several roots: tool paths carry the name as their first component.
216    prefixed: bool,
217}
218
219impl Root {
220    /// A root-relative path as the tools address it.
221    fn show(&self, rel: &Path) -> String {
222        if !self.prefixed {
223            return path::display(rel);
224        }
225        if rel.as_os_str().is_empty() {
226            self.name.clone()
227        } else {
228            format!("{}/{}", self.name, path::display(rel))
229        }
230    }
231
232    /// The walker's absolute path made relative to this root; `None` if it is not under it.
233    fn to_relative(&self, abs: &Path) -> Option<PathBuf> {
234        let rel = abs.strip_prefix(&self.path).ok()?;
235        // Re-validate: the walker never produces `..`, but nothing reaches the Dir unchecked.
236        path::relative(rel.to_str()?).ok()
237    }
238}
239
240/// A tool path resolved to a root and a path inside it, or the virtual top of several roots.
241enum Target<'a> {
242    Top,
243    In(&'a Root, PathBuf),
244}
245
246/// The root folders, opened once, and the operator's limits.
247#[derive(Debug)]
248pub struct Folder {
249    roots: Vec<Root>,
250    config: Config,
251}
252
253impl Folder {
254    /// Open the configured roots. Fails when one does not exist or is not a directory, when two
255    /// roots share a name, or when a name is not a single plain path component.
256    pub fn open(config: Config) -> Result<Self, FolderError> {
257        if config.roots.is_empty() {
258            return Err(FolderError::Invalid("no root folder configured".into()));
259        }
260        let prefixed = config.roots.len() > 1;
261        let mut roots: Vec<Root> = Vec::with_capacity(config.roots.len());
262        for spec in &config.roots {
263            let shown = spec.path.display().to_string();
264            let canonical = spec
265                .path
266                .canonicalize()
267                .map_err(|e| FolderError::from_io(e, &shown))?;
268            if !canonical.is_dir() {
269                return Err(FolderError::Invalid(format!(
270                    "root {shown} is not a directory"
271                )));
272            }
273            let name = match &spec.name {
274                Some(n) => n.clone(),
275                None => canonical
276                    .file_name()
277                    .map(|n| n.to_string_lossy().into_owned())
278                    .unwrap_or_else(|| "root".into()),
279            };
280            if !valid_root_name(&name) {
281                return Err(FolderError::Invalid(format!(
282                    "root name {name:?} must be one plain path component (no /, \\, . or ..)"
283                )));
284            }
285            if prefixed && roots.iter().any(|r| r.name == name) {
286                return Err(FolderError::Invalid(format!(
287                    "two roots are named {name:?}; name them with --root NAME=DIR"
288                )));
289            }
290            let dir = Dir::open_ambient_dir(&canonical, ambient_authority())
291                .map_err(|e| FolderError::from_io(e, &shown))?;
292            roots.push(Root {
293                name,
294                dir,
295                path: canonical,
296                prefixed,
297            });
298        }
299        Ok(Self { roots, config })
300    }
301
302    /// Each root's name and canonical path (for logs and `doctor`; never used to open files).
303    pub fn roots(&self) -> impl Iterator<Item = (&str, &Path)> {
304        self.roots
305            .iter()
306            .map(|r| (r.name.as_str(), r.path.as_path()))
307    }
308
309    /// The configuration in force.
310    pub fn config(&self) -> &Config {
311        &self.config
312    }
313
314    /// `list`: entries under `path` (the root when empty), optionally filtered by a glob on the
315    /// path as the tools address it, one level deep unless `recursive`.
316    pub fn list(
317        &self,
318        path: &str,
319        glob: Option<&str>,
320        recursive: bool,
321        max_entries: Option<usize>,
322    ) -> Result<Value, FolderError> {
323        let target = self.resolve(path)?;
324        let matcher = glob.map(compile_glob).transpose()?;
325        let limit = self.cap(max_entries);
326        let mut entries = Vec::new();
327        let mut truncated = false;
328        let shown = match target {
329            Target::In(root, rel) => {
330                self.require_dir(root, &rel, path)?;
331                truncated =
332                    self.list_in(root, &rel, matcher.as_ref(), recursive, limit, &mut entries);
333                root.show(&rel)
334            }
335            Target::Top => {
336                for root in &self.roots {
337                    if matcher.as_ref().is_none_or(|m| m.is_match(&root.name)) {
338                        if entries.len() == limit {
339                            truncated = true;
340                            break;
341                        }
342                        entries.push(json!({ "path": root.name, "kind": "dir" }));
343                    }
344                    if recursive
345                        && self.list_in(
346                            root,
347                            Path::new(""),
348                            matcher.as_ref(),
349                            true,
350                            limit,
351                            &mut entries,
352                        )
353                    {
354                        truncated = true;
355                        break;
356                    }
357                }
358                ".".to_string()
359            }
360        };
361        Ok(json!({
362            "path": shown,
363            "entries": entries,
364            "truncated": truncated,
365        }))
366    }
367
368    /// `read`: a text file, whole or lines `start_line..=end_line` (1-based), or a small image.
369    ///
370    /// A whole-file read is refused above the size limit; a line-range read streams the file and
371    /// stops at `end_line`. Either returns at most the byte limit and the character limit.
372    pub fn read(
373        &self,
374        path: &str,
375        start_line: Option<usize>,
376        end_line: Option<usize>,
377    ) -> Result<ReadOutput, FolderError> {
378        let (root, rel) = self.resolve_entry(path)?;
379        let shown = root.show(&rel);
380        let md = root
381            .dir
382            .metadata(&rel)
383            .map_err(|e| FolderError::from_io(e, &shown))?;
384        if md.is_dir() {
385            return Err(FolderError::Invalid(format!(
386                "{shown} is a directory; use list"
387            )));
388        }
389        let limit = self.config.max_file_bytes;
390        let ranged = start_line.is_some() || end_line.is_some();
391        if !ranged && md.len() > limit {
392            return Err(FolderError::TooLarge {
393                path: shown,
394                size: md.len(),
395                limit,
396                hint: "; read a line range with start_line and end_line",
397            });
398        }
399        let start = start_line.unwrap_or(1).max(1);
400        let end = end_line.unwrap_or(usize::MAX);
401        if end < start {
402            return Err(FolderError::Invalid(format!(
403                "end_line {end} is before start_line {start}"
404            )));
405        }
406
407        let file = root
408            .dir
409            .open(&rel)
410            .map_err(|e| FolderError::from_io(e, &shown))?;
411        let io = |e| FolderError::from_io(e, &shown);
412        let mut reader = BufReader::with_capacity(64 * 1024, file);
413        let head = reader.fill_buf().map_err(io)?;
414        if let Some(mime) = image_mime(head)
415            && !ranged
416            && md.len() <= MAX_IMAGE_BYTES
417        {
418            let mut data = Vec::with_capacity(md.len() as usize);
419            reader
420                .take(limit.min(MAX_IMAGE_BYTES) + 1)
421                .read_to_end(&mut data)
422                .map_err(io)?;
423            if data.len() as u64 <= limit.min(MAX_IMAGE_BYTES) {
424                return Ok(ReadOutput::Image {
425                    path: shown,
426                    mime,
427                    data,
428                });
429            }
430            // Grew past the limit while being read: report it like any binary file.
431            return Ok(ReadOutput::Binary {
432                path: shown,
433                size: data.len() as u64,
434            });
435        }
436        if is_binary(head) {
437            return Ok(ReadOutput::Binary {
438                path: shown,
439                size: md.len(),
440            });
441        }
442
443        let max_chars = self.config.max_chars.max(1);
444        let mut out: Vec<u8> = Vec::new();
445        let mut chars = 0usize;
446        let mut line = 0usize; // lines fully consumed
447        let mut first = 0usize;
448        let mut truncated = false;
449        let mut partial = false;
450        let mut at_eof = false;
451        while line < end {
452            if line + 1 < start {
453                if reader.skip_until(b'\n').map_err(io)? == 0 {
454                    at_eof = true;
455                    break;
456                }
457                line += 1;
458                continue;
459            }
460            let before = out.len();
461            let budget = limit - before as u64;
462            let n = (&mut reader)
463                .take(budget + 1)
464                .read_until(b'\n', &mut out)
465                .map_err(io)?;
466            if n == 0 {
467                at_eof = true;
468                break;
469            }
470            line += 1;
471            if first == 0 {
472                first = line;
473            }
474            if out.len() as u64 > limit {
475                out.truncate(limit as usize);
476                truncated = true;
477                partial = true;
478                break;
479            }
480            let line_chars = count_chars(&out[before..]);
481            if chars + line_chars > max_chars {
482                truncated = true;
483                if line == first {
484                    // One line longer than the whole budget: return its start.
485                    let keep = byte_index_of_char(&out[before..], max_chars - chars);
486                    out.truncate(before + keep);
487                    partial = true;
488                } else {
489                    out.truncate(before);
490                    line -= 1;
491                }
492                break;
493            }
494            chars += line_chars;
495        }
496        if !at_eof && !truncated {
497            // Stopped at end_line: the file is complete if nothing follows.
498            at_eof = reader.fill_buf().map_err(io)?.is_empty();
499        }
500        let last = if first == 0 { 0 } else { line };
501        Ok(ReadOutput::Text {
502            path: shown,
503            content: String::from_utf8_lossy(&out).into_owned(),
504            first_line: first,
505            last_line: last,
506            total_lines: (at_eof && !truncated).then_some(line),
507            truncated,
508            partial_last_line: partial,
509        })
510    }
511
512    /// `search`: lines matching `pattern` in text files under `path` (or in that one file).
513    pub fn search(
514        &self,
515        pattern: &str,
516        path: &str,
517        glob: Option<&str>,
518        ignore_case: bool,
519    ) -> Result<Value, FolderError> {
520        let re = RegexBuilder::new(pattern)
521            .case_insensitive(ignore_case)
522            .size_limit(REGEX_SIZE_LIMIT)
523            .build()
524            .map_err(|e| FolderError::Invalid(format!("invalid regex: {e}")))?;
525        let matcher = glob.map(compile_glob).transpose()?;
526        let limit = self.config.max_results;
527
528        let mut files: Vec<(&Root, PathBuf)> = Vec::new();
529        match self.resolve(path)? {
530            Target::Top => {
531                for root in &self.roots {
532                    self.collect_files(root, Path::new(""), matcher.as_ref(), &mut files);
533                }
534            }
535            Target::In(root, rel) => {
536                let shown = root.show(&rel);
537                let md = root
538                    .dir
539                    .metadata(path::for_dir(&rel))
540                    .map_err(|e| FolderError::from_io(e, &shown))?;
541                if md.is_file() {
542                    files.push((root, rel));
543                } else {
544                    self.collect_files(root, &rel, matcher.as_ref(), &mut files);
545                }
546            }
547        }
548
549        let mut matches = Vec::new();
550        let (mut searched, mut skipped_binary, mut skipped_large) = (0usize, 0usize, 0usize);
551        let mut truncated = false;
552        'files: for (root, file_rel) in files {
553            let Ok(fmd) = root.dir.symlink_metadata(&file_rel) else {
554                continue;
555            };
556            if !fmd.is_file() {
557                continue;
558            }
559            if fmd.len() > self.config.max_file_bytes {
560                skipped_large += 1;
561                continue;
562            }
563            let Ok(bytes) = root.dir.read(&file_rel) else {
564                continue;
565            };
566            if is_binary(&bytes) {
567                skipped_binary += 1;
568                continue;
569            }
570            searched += 1;
571            let text = String::from_utf8_lossy(&bytes);
572            for (i, line) in text.lines().enumerate() {
573                if re.is_match(line) {
574                    if matches.len() == limit {
575                        truncated = true;
576                        break 'files;
577                    }
578                    matches.push(json!({
579                        "path": root.show(&file_rel),
580                        "line": i + 1,
581                        "text": clip(line, MAX_MATCH_LINE_CHARS),
582                    }));
583                }
584            }
585        }
586        Ok(json!({
587            "pattern": pattern,
588            "matches": matches,
589            "files_searched": searched,
590            "skipped_binary": skipped_binary,
591            "skipped_too_large": skipped_large,
592            "truncated": truncated,
593        }))
594    }
595
596    /// `write`: put `content` at `path`, creating parent directories.
597    pub fn write(&self, path: &str, content: &str, mode: WriteMode) -> Result<Value, FolderError> {
598        self.require_writable()?;
599        let (root, rel) = self.resolve_entry(path)?;
600        let shown = root.show(&rel);
601        let size = content.len() as u64;
602        if size > self.config.max_file_bytes {
603            return Err(FolderError::TooLarge {
604                path: shown,
605                size,
606                limit: self.config.max_file_bytes,
607                hint: "",
608            });
609        }
610        create_parent(root, &rel, &shown)?;
611        let mut opts = OpenOptions::new();
612        match mode {
613            WriteMode::Create => opts.write(true).create_new(true),
614            WriteMode::Overwrite => opts.write(true).create(true).truncate(true),
615            WriteMode::Append => opts.append(true).create(true),
616        };
617        let mut file =
618            root.dir
619                .open_with(&rel, &opts)
620                .map_err(|e| match FolderError::from_io(e, &shown) {
621                    FolderError::Exists(_) => FolderError::Exists(format!(
622                        "{shown} already exists; use mode overwrite or append"
623                    )),
624                    other => other,
625                })?;
626        file.write_all(content.as_bytes())
627            .map_err(|e| FolderError::from_io(e, &shown))?;
628        Ok(json!({ "path": shown, "mode": mode, "bytes_written": size }))
629    }
630
631    /// `edit`: replace `old_text` with `new_text` in a text file. `old_text` must occur exactly
632    /// once unless `replace_all`.
633    pub fn edit(
634        &self,
635        path: &str,
636        old_text: &str,
637        new_text: &str,
638        replace_all: bool,
639    ) -> Result<Value, FolderError> {
640        self.require_writable()?;
641        if old_text.is_empty() {
642            return Err(FolderError::Invalid(
643                "old_text is empty; give the exact text to replace".into(),
644            ));
645        }
646        let (root, rel) = self.resolve_entry(path)?;
647        let shown = root.show(&rel);
648        let md = root
649            .dir
650            .metadata(&rel)
651            .map_err(|e| FolderError::from_io(e, &shown))?;
652        if md.is_dir() {
653            return Err(FolderError::Invalid(format!(
654                "{shown} is a directory; edit works on files"
655            )));
656        }
657        let limit = self.config.max_file_bytes;
658        if md.len() > limit {
659            return Err(FolderError::TooLarge {
660                path: shown,
661                size: md.len(),
662                limit,
663                hint: "",
664            });
665        }
666        let bytes = root
667            .dir
668            .read(&rel)
669            .map_err(|e| FolderError::from_io(e, &shown))?;
670        if is_binary(&bytes) {
671            return Err(FolderError::Invalid(format!(
672                "{shown} is a binary file; edit works on text files"
673            )));
674        }
675        let text = String::from_utf8(bytes).map_err(|_| {
676            FolderError::Invalid(format!(
677                "{shown} is not valid UTF-8; edit it with write instead"
678            ))
679        })?;
680        let count = text.matches(old_text).count();
681        if count == 0 {
682            return Err(FolderError::Invalid(format!(
683                "old_text was not found in {shown}; read the file and copy the text exactly, whitespace included"
684            )));
685        }
686        if count > 1 && !replace_all {
687            return Err(FolderError::Invalid(format!(
688                "old_text occurs {count} times in {shown}; include more surrounding text so it occurs exactly once, or pass replace_all: true"
689            )));
690        }
691        let updated = if replace_all {
692            text.replace(old_text, new_text)
693        } else {
694            text.replacen(old_text, new_text, 1)
695        };
696        let size = updated.len() as u64;
697        if size > limit {
698            return Err(FolderError::TooLarge {
699                path: shown,
700                size,
701                limit,
702                hint: " after the edit",
703            });
704        }
705        let mut opts = OpenOptions::new();
706        opts.write(true).truncate(true);
707        let mut file = root
708            .dir
709            .open_with(&rel, &opts)
710            .map_err(|e| FolderError::from_io(e, &shown))?;
711        file.write_all(updated.as_bytes())
712            .map_err(|e| FolderError::from_io(e, &shown))?;
713        Ok(json!({ "path": shown, "replacements": count, "bytes_written": size }))
714    }
715
716    /// `move`: rename `from` to `to` (within a root or across roots); refuses to replace `to`
717    /// unless `overwrite`.
718    pub fn move_entry(&self, from: &str, to: &str, overwrite: bool) -> Result<Value, FolderError> {
719        self.require_writable()?;
720        let (from_root, from_rel) = self.resolve_entry(from)?;
721        let (to_root, to_rel) = self.resolve_entry(to)?;
722        let (from_shown, to_shown) = (from_root.show(&from_rel), to_root.show(&to_rel));
723        from_root
724            .dir
725            .symlink_metadata(&from_rel)
726            .map_err(|e| FolderError::from_io(e, &from_shown))?;
727        let same_root = std::ptr::eq(from_root, to_root);
728        if same_root && from_rel == to_rel {
729            return Err(FolderError::Invalid(format!(
730                "{from_shown} and {to_shown} are the same path"
731            )));
732        }
733        if same_root && to_rel.starts_with(&from_rel) {
734            return Err(FolderError::Invalid(format!(
735                "cannot move {from_shown} into itself"
736            )));
737        }
738        if !overwrite && to_root.dir.symlink_metadata(&to_rel).is_ok() {
739            return Err(FolderError::Exists(format!(
740                "{to_shown} already exists; pass overwrite: true to replace it"
741            )));
742        }
743        create_parent(to_root, &to_rel, &to_shown)?;
744        from_root
745            .dir
746            .rename(&from_rel, &to_root.dir, &to_rel)
747            .map_err(|e| {
748                if !same_root && e.kind() == std::io::ErrorKind::CrossesDevices {
749                    FolderError::Invalid(format!(
750                        "{from_shown} and {to_shown} are on different file systems; read and write the file instead"
751                    ))
752                } else {
753                    FolderError::from_io(e, &from_shown)
754                }
755            })?;
756        Ok(json!({ "from": from_shown, "to": to_shown }))
757    }
758
759    /// `stat`: kind, size, modified time, and line count for a text file within the size limit.
760    pub fn stat(&self, path: &str) -> Result<Value, FolderError> {
761        let (root, rel) = match self.resolve(path)? {
762            Target::Top => {
763                let names: Vec<&str> = self.roots.iter().map(|r| r.name.as_str()).collect();
764                return Ok(json!({ "path": ".", "kind": "dir", "roots": names }));
765            }
766            Target::In(root, rel) => (root, rel),
767        };
768        let shown = root.show(&rel);
769        let md = root
770            .dir
771            .symlink_metadata(path::for_dir(&rel))
772            .map_err(|e| FolderError::from_io(e, &shown))?;
773        let mut out = json!({
774            "path": shown,
775            "kind": kind(&md),
776            "size": md.len(),
777            "readonly": md.permissions().readonly(),
778        });
779        if let Some(secs) = md
780            .modified()
781            .ok()
782            .and_then(|t| t.into_std().duration_since(UNIX_EPOCH).ok())
783        {
784            out["modified"] = json!(rfc3339_utc(secs.as_secs()));
785        }
786        if md.is_file() && md.len() <= self.config.max_file_bytes {
787            let bytes = root
788                .dir
789                .read(&rel)
790                .map_err(|e| FolderError::from_io(e, &shown))?;
791            if let Some(mime) = image_mime(&bytes) {
792                out["binary"] = json!(true);
793                out["image"] = json!(mime);
794            } else if is_binary(&bytes) {
795                out["binary"] = json!(true);
796            } else {
797                out["binary"] = json!(false);
798                out["lines"] = json!(count_lines(&bytes));
799            }
800        }
801        Ok(out)
802    }
803
804    /// Number of files under all roots the walker sees, stopping once `stop_after` is passed.
805    pub fn count_files(&self, stop_after: usize) -> usize {
806        let mut n = 0usize;
807        for root in &self.roots {
808            n += self
809                .walker(root, Path::new(""))
810                .build()
811                .flatten()
812                .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
813                .take(stop_after.saturating_add(1) - n)
814                .count();
815            if n > stop_after {
816                break;
817            }
818        }
819        n
820    }
821
822    /* --- helpers ------------------------------------------------------------------------------ */
823
824    /// Resolve a tool path: with one root, a root-relative path; with several, the first
825    /// component names the root and `.` is the virtual top.
826    fn resolve(&self, input: &str) -> Result<Target<'_>, FolderError> {
827        let rel = path::relative(input)?;
828        if self.roots.len() == 1 {
829            return Ok(Target::In(&self.roots[0], rel));
830        }
831        let mut parts = rel.components();
832        let Some(Component::Normal(first)) = parts.next() else {
833            return Ok(Target::Top);
834        };
835        let first = first.to_string_lossy();
836        let root = self.roots.iter().find(|r| r.name == first).ok_or_else(|| {
837            let names: Vec<&str> = self.roots.iter().map(|r| r.name.as_str()).collect();
838            FolderError::NotFound(format!(
839                "{input}: there is no root named {first}; paths start with one of: {}",
840                names.join(", ")
841            ))
842        })?;
843        Ok(Target::In(root, parts.as_path().to_path_buf()))
844    }
845
846    /// Like [`Self::resolve`], for tools that act on one entry: a root itself is refused.
847    fn resolve_entry(&self, input: &str) -> Result<(&Root, PathBuf), FolderError> {
848        match self.resolve(input)? {
849            Target::In(root, rel) if !rel.as_os_str().is_empty() => Ok((root, rel)),
850            Target::In(root, _) if root.prefixed => Err(FolderError::Invalid(format!(
851                "{} is a root folder; give a file path inside it",
852                root.name
853            ))),
854            _ => Err(FolderError::Invalid(
855                "a file path is required; the root folder itself is not a file".into(),
856            )),
857        }
858    }
859
860    fn require_writable(&self) -> Result<(), FolderError> {
861        if self.config.writable {
862            Ok(())
863        } else {
864            Err(FolderError::ReadOnly)
865        }
866    }
867
868    fn cap(&self, requested: Option<usize>) -> usize {
869        requested
870            .unwrap_or(self.config.max_results)
871            .clamp(1, self.config.max_results.max(1))
872    }
873
874    fn require_dir(&self, root: &Root, rel: &Path, asked: &str) -> Result<(), FolderError> {
875        let md = root
876            .dir
877            .metadata(path::for_dir(rel))
878            .map_err(|e| FolderError::from_io(e, asked))?;
879        if md.is_dir() {
880            Ok(())
881        } else {
882            Err(FolderError::Invalid(format!(
883                "{} is not a directory; use read or stat",
884                root.show(rel)
885            )))
886        }
887    }
888
889    /// Append the entries under `rel` to `entries`; true when `limit` cut the listing short.
890    fn list_in(
891        &self,
892        root: &Root,
893        rel: &Path,
894        matcher: Option<&GlobMatcher>,
895        recursive: bool,
896        limit: usize,
897        entries: &mut Vec<Value>,
898    ) -> bool {
899        let mut walker = self.walker(root, rel);
900        if !recursive {
901            walker.max_depth(Some(1));
902        }
903        for entry in walker.build().flatten() {
904            if entry.depth() == 0 {
905                continue;
906            }
907            let Some(entry_rel) = root.to_relative(entry.path()) else {
908                continue;
909            };
910            let shown = root.show(&entry_rel);
911            if matcher.is_some_and(|m| !m.is_match(&shown)) {
912                continue;
913            }
914            // Re-open through the capability handle; anything it refuses is not listed.
915            let Ok(md) = root.dir.symlink_metadata(&entry_rel) else {
916                continue;
917            };
918            if entries.len() == limit {
919                return true;
920            }
921            let mut item = json!({ "path": shown, "kind": kind(&md) });
922            if md.is_file() {
923                item["size"] = json!(md.len());
924            }
925            entries.push(item);
926        }
927        false
928    }
929
930    /// Files under `rel` (a directory) that pass the glob.
931    fn collect_files<'a>(
932        &self,
933        root: &'a Root,
934        rel: &Path,
935        matcher: Option<&GlobMatcher>,
936        files: &mut Vec<(&'a Root, PathBuf)>,
937    ) {
938        files.extend(
939            self.walker(root, rel)
940                .build()
941                .flatten()
942                .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
943                .filter_map(|e| root.to_relative(e.path()))
944                .filter(|r| matcher.is_none_or(|m| m.is_match(root.show(r))))
945                .map(|r| (root, r)),
946        );
947    }
948
949    /// A walker over `rel` in `root` (already checked to be a directory through the `Dir`).
950    fn walker(&self, root: &Root, rel: &Path) -> WalkBuilder {
951        let mut b = WalkBuilder::new(root.path.join(rel));
952        let honour_ignore = !self.config.no_ignore;
953        b.hidden(!self.config.hidden)
954            .ignore(honour_ignore)
955            .git_ignore(honour_ignore)
956            .git_global(false)
957            .git_exclude(honour_ignore)
958            .parents(honour_ignore)
959            // .gitignore applies even when the root is not a git checkout.
960            .require_git(false)
961            .follow_links(false)
962            .sort_by_file_name(|a, b| a.cmp(b));
963        b
964    }
965}
966
967fn create_parent(root: &Root, rel: &Path, shown: &str) -> Result<(), FolderError> {
968    if let Some(parent) = rel.parent()
969        && !parent.as_os_str().is_empty()
970    {
971        match root.dir.create_dir_all(parent) {
972            Ok(()) => {}
973            // cap-std answers "exists" for a symlink in the way; resolve it to say whether
974            // it is a usable directory or a link out of the root.
975            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
976                match root.dir.metadata(parent) {
977                    Ok(md) if md.is_dir() => {}
978                    Ok(_) => {
979                        return Err(FolderError::Invalid(format!(
980                            "{} is not a directory",
981                            root.show(parent)
982                        )));
983                    }
984                    Err(e) => return Err(FolderError::from_io(e, shown)),
985                }
986            }
987            Err(e) => return Err(FolderError::from_io(e, shown)),
988        }
989    }
990    Ok(())
991}
992
993/// A root name is one plain path component.
994fn valid_root_name(name: &str) -> bool {
995    !name.is_empty()
996        && name != "."
997        && name != ".."
998        && !name.contains(['/', '\\'])
999        && name.trim() == name
1000}
1001
1002/// The image type, from the file's magic bytes: PNG, JPEG, GIF or WebP.
1003fn image_mime(head: &[u8]) -> Option<&'static str> {
1004    if head.starts_with(b"\x89PNG\r\n\x1a\n") {
1005        Some("image/png")
1006    } else if head.starts_with(&[0xFF, 0xD8, 0xFF]) {
1007        Some("image/jpeg")
1008    } else if head.starts_with(b"GIF87a") || head.starts_with(b"GIF89a") {
1009        Some("image/gif")
1010    } else if head.len() >= 12 && head.starts_with(b"RIFF") && &head[8..12] == b"WEBP" {
1011        Some("image/webp")
1012    } else {
1013        None
1014    }
1015}
1016
1017/// Characters in UTF-8 bytes (invalid bytes count as one each, as lossy decoding shows them).
1018fn count_chars(bytes: &[u8]) -> usize {
1019    bytes.iter().filter(|&&b| (b & 0xC0) != 0x80).count()
1020}
1021
1022/// Byte offset of the `n`th character (or the end), never inside a UTF-8 sequence.
1023fn byte_index_of_char(bytes: &[u8], n: usize) -> usize {
1024    let mut seen = 0usize;
1025    for (i, &b) in bytes.iter().enumerate() {
1026        if (b & 0xC0) != 0x80 {
1027            if seen == n {
1028                return i;
1029            }
1030            seen += 1;
1031        }
1032    }
1033    bytes.len()
1034}
1035
1036fn compile_glob(pattern: &str) -> Result<GlobMatcher, FolderError> {
1037    GlobBuilder::new(pattern)
1038        .literal_separator(false)
1039        .build()
1040        .map(|g| g.compile_matcher())
1041        .map_err(|e| FolderError::Invalid(format!("invalid glob {pattern:?}: {e}")))
1042}
1043
1044fn kind(md: &Metadata) -> &'static str {
1045    let ft = md.file_type();
1046    if ft.is_symlink() {
1047        "symlink"
1048    } else if ft.is_dir() {
1049        "dir"
1050    } else if ft.is_file() {
1051        "file"
1052    } else {
1053        "other"
1054    }
1055}
1056
1057/// A NUL byte in the first 8 KiB means binary.
1058fn is_binary(bytes: &[u8]) -> bool {
1059    bytes[..bytes.len().min(BINARY_SNIFF_BYTES)].contains(&0)
1060}
1061
1062fn count_lines(bytes: &[u8]) -> usize {
1063    let newlines = bytes.iter().filter(|&&b| b == b'\n').count();
1064    if bytes.last().is_some_and(|&b| b != b'\n') {
1065        newlines + 1
1066    } else {
1067        newlines
1068    }
1069}
1070
1071fn clip(line: &str, max_chars: usize) -> String {
1072    match line.char_indices().nth(max_chars) {
1073        Some((idx, _)) => format!("{}…", &line[..idx]),
1074        None => line.to_string(),
1075    }
1076}
1077
1078/// Seconds since the Unix epoch as an RFC 3339 UTC timestamp, without a date crate.
1079fn rfc3339_utc(secs: u64) -> String {
1080    let days = (secs / 86_400) as i64;
1081    let rem = secs % 86_400;
1082    // Civil-from-days (Howard Hinnant's algorithm).
1083    let z = days + 719_468;
1084    let era = z.div_euclid(146_097);
1085    let doe = z.rem_euclid(146_097);
1086    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
1087    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1088    let mp = (5 * doy + 2) / 153;
1089    let day = doy - (153 * mp + 2) / 5 + 1;
1090    let month = if mp < 10 { mp + 3 } else { mp - 9 };
1091    let year = yoe + era * 400 + i64::from(month <= 2);
1092    format!(
1093        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
1094        rem / 3_600,
1095        (rem % 3_600) / 60,
1096        rem % 60
1097    )
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102    use super::*;
1103
1104    #[test]
1105    fn timestamps_are_rfc3339() {
1106        assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z");
1107        assert_eq!(rfc3339_utc(1_700_000_000), "2023-11-14T22:13:20Z");
1108        assert_eq!(rfc3339_utc(951_782_400), "2000-02-29T00:00:00Z");
1109    }
1110
1111    #[test]
1112    fn line_counting() {
1113        assert_eq!(count_lines(b""), 0);
1114        assert_eq!(count_lines(b"a"), 1);
1115        assert_eq!(count_lines(b"a\n"), 1);
1116        assert_eq!(count_lines(b"a\nb"), 2);
1117    }
1118
1119    #[test]
1120    fn binary_sniff_and_clip() {
1121        assert!(is_binary(b"ab\0cd"));
1122        assert!(!is_binary(b"plain text"));
1123        assert_eq!(clip("abcdef", 3), "abc…");
1124        assert_eq!(clip("żółw", 10), "żółw");
1125    }
1126
1127    #[test]
1128    fn glob_matches_root_relative_paths() {
1129        let m = compile_glob("*.rs").unwrap();
1130        assert!(m.is_match("src/main.rs"));
1131        assert!(!m.is_match("README.md"));
1132        assert!(compile_glob("a[").is_err());
1133    }
1134
1135    #[test]
1136    fn root_specs_parse_optional_names() {
1137        assert_eq!("docs".parse::<RootSpec>().unwrap(), RootSpec::new("docs"));
1138        assert_eq!(
1139            "notes=/home/me/notes".parse::<RootSpec>().unwrap(),
1140            RootSpec::named("notes", "/home/me/notes")
1141        );
1142        // A separator before the `=` means it is part of the path.
1143        assert_eq!("./a=b".parse::<RootSpec>().unwrap(), RootSpec::new("./a=b"));
1144        assert!("x=".parse::<RootSpec>().is_err());
1145        assert!("".parse::<RootSpec>().is_err());
1146        assert!(valid_root_name("docs") && !valid_root_name("..") && !valid_root_name("a/b"));
1147    }
1148
1149    #[test]
1150    fn images_are_sniffed_by_magic_bytes() {
1151        assert_eq!(image_mime(b"\x89PNG\r\n\x1a\nrest"), Some("image/png"));
1152        assert_eq!(image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), Some("image/jpeg"));
1153        assert_eq!(image_mime(b"GIF89a..."), Some("image/gif"));
1154        assert_eq!(image_mime(b"RIFF\0\0\0\0WEBPVP8 "), Some("image/webp"));
1155        assert_eq!(image_mime(b"RIFF\0\0\0\0WAVE"), None);
1156        assert_eq!(image_mime(b"plain"), None);
1157    }
1158
1159    #[test]
1160    fn character_counting_respects_utf8() {
1161        let s = "żółw🐢a".as_bytes();
1162        assert_eq!(count_chars(s), 6);
1163        assert_eq!(byte_index_of_char(s, 4), "żółw".len());
1164        assert_eq!(byte_index_of_char(s, 99), s.len());
1165    }
1166}