1use 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
35pub const DEFAULT_MAX_FILE_BYTES: u64 = 1024 * 1024;
37pub const DEFAULT_MAX_RESULTS: usize = 200;
39pub const DEFAULT_MAX_CHARS: usize = limits::DEFAULT_MAX_CHARS;
41pub const MAX_IMAGE_BYTES: u64 = 5 * 1024 * 1024;
43const BINARY_SNIFF_BYTES: usize = 8 * 1024;
45const MAX_MATCH_LINE_CHARS: usize = 240;
47const REGEX_SIZE_LIMIT: usize = 1024 * 1024;
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct RootSpec {
53 pub name: Option<String>,
56 pub path: PathBuf,
58}
59
60impl RootSpec {
61 pub fn new(path: impl Into<PathBuf>) -> Self {
63 Self {
64 name: None,
65 path: path.into(),
66 }
67 }
68
69 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 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#[derive(Debug, Clone)]
101pub struct Config {
102 pub roots: Vec<RootSpec>,
104 pub writable: bool,
106 pub hidden: bool,
108 pub no_ignore: bool,
110 pub max_file_bytes: u64,
112 pub max_results: usize,
114 pub max_chars: usize,
116}
117
118impl Config {
119 pub fn new(root: impl Into<PathBuf>) -> Self {
121 Self::with_roots(vec![RootSpec::new(root)])
122 }
123
124 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 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 pub fn writable(mut self, writable: bool) -> Self {
145 self.writable = writable;
146 self
147 }
148
149 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#[derive(
158 Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema,
159)]
160#[serde(rename_all = "lowercase")]
161pub enum WriteMode {
162 #[default]
164 Create,
165 Overwrite,
167 Append,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173pub enum ReadOutput {
174 Text {
176 path: String,
178 content: String,
180 first_line: usize,
182 last_line: usize,
184 total_lines: Option<usize>,
186 truncated: bool,
188 partial_last_line: bool,
190 },
191 Image {
193 path: String,
195 mime: &'static str,
197 data: Vec<u8>,
199 },
200 Binary {
202 path: String,
204 size: u64,
206 },
207}
208
209#[derive(Debug)]
211struct Root {
212 name: String,
213 dir: Dir,
214 path: PathBuf,
215 prefixed: bool,
217}
218
219impl Root {
220 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 fn to_relative(&self, abs: &Path) -> Option<PathBuf> {
234 let rel = abs.strip_prefix(&self.path).ok()?;
235 path::relative(rel.to_str()?).ok()
237 }
238}
239
240enum Target<'a> {
242 Top,
243 In(&'a Root, PathBuf),
244}
245
246#[derive(Debug)]
248pub struct Folder {
249 roots: Vec<Root>,
250 config: Config,
251}
252
253impl Folder {
254 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 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 pub fn config(&self) -> &Config {
311 &self.config
312 }
313
314 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 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 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; 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 .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 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
993fn valid_root_name(name: &str) -> bool {
995 !name.is_empty()
996 && name != "."
997 && name != ".."
998 && !name.contains(['/', '\\'])
999 && name.trim() == name
1000}
1001
1002fn 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
1017fn count_chars(bytes: &[u8]) -> usize {
1019 bytes.iter().filter(|&&b| (b & 0xC0) != 0x80).count()
1020}
1021
1022fn 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
1057fn 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
1078fn rfc3339_utc(secs: u64) -> String {
1080 let days = (secs / 86_400) as i64;
1081 let rem = secs % 86_400;
1082 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 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}