Skip to main content

kowalski_core/tools/internal/
file_system.rs

1//! **Internal filesystem** — small, synchronous helpers for bounded read/write/list/copy.
2//!
3//! These are **not** a full virtual FS for agents: no glob-from-arbitrary-root without caller checks.
4//! Prefer **MCP** for vault-wide indexing, git operations, or cross-machine sync.
5
6use std::fs;
7use std::io::{Read, Write};
8use std::path::{Path, PathBuf};
9
10/// Default cap for [`read_file_bounded`] when used from ingest-style callers.
11pub const DEFAULT_MAX_READ_BYTES: usize = 256 * 1024;
12
13/// Marker for future `Tool` registration (`internal_fs_read`, etc.).
14#[derive(Debug, Clone, Copy, Default)]
15pub struct FileSystemInternalModule;
16
17pub fn path_exists(path: &Path) -> bool {
18    path.exists()
19}
20
21pub fn is_file(path: &Path) -> bool {
22    path.is_file()
23}
24
25pub fn is_dir(path: &Path) -> bool {
26    path.is_dir()
27}
28
29/// File size in bytes, or error if missing / not a file.
30pub fn file_len(path: &Path) -> Result<u64, String> {
31    let m = fs::metadata(path).map_err(|e| e.to_string())?;
32    if !m.is_file() {
33        return Err("path is not a regular file".into());
34    }
35    Ok(m.len())
36}
37
38/// Read entire file as UTF-8 if `metadata().len() <= max_bytes`; otherwise error (no partial read that hides truncation).
39pub fn read_file_bounded(path: &Path, max_bytes: usize) -> Result<String, String> {
40    let len = file_len(path)?;
41    if len > max_bytes as u64 {
42        return Err(format!(
43            "file size {} exceeds max_bytes {}",
44            len, max_bytes
45        ));
46    }
47    fs::read_to_string(path).map_err(|e| e.to_string())
48}
49
50/// Read at most `max_bytes` from the start of a file (binary-safe slice, then UTF-8 lossy is wrong for binary — use for text only).
51pub fn read_file_prefix(path: &Path, max_bytes: usize) -> Result<String, String> {
52    let mut f = fs::File::open(path).map_err(|e| e.to_string())?;
53    let mut buf = vec![0u8; max_bytes];
54    let n = f.read(&mut buf).map_err(|e| e.to_string())?;
55    buf.truncate(n);
56    String::from_utf8(buf).map_err(|e| format!("invalid UTF-8: {}", e))
57}
58
59/// Write `contents` to `path`, creating parent directories.
60pub fn write_file(path: &Path, contents: &str) -> Result<(), String> {
61    if let Some(parent) = path.parent() {
62        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
63    }
64    fs::write(path, contents.as_bytes()).map_err(|e| e.to_string())
65}
66
67/// Append UTF-8 to `path` (creates file if missing).
68pub fn append_file(path: &Path, contents: &str) -> Result<(), String> {
69    if let Some(parent) = path.parent() {
70        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
71    }
72    let mut f = fs::OpenOptions::new()
73        .create(true)
74        .append(true)
75        .open(path)
76        .map_err(|e| e.to_string())?;
77    f.write_all(contents.as_bytes()).map_err(|e| e.to_string())
78}
79
80/// `fs::create_dir_all`
81pub fn mkdir_all(path: &Path) -> Result<(), String> {
82    fs::create_dir_all(path).map_err(|e| e.to_string())
83}
84
85/// Remove a single file (not a directory). Intended for scratch cleanup in controlled paths.
86pub fn remove_file(path: &Path) -> Result<(), String> {
87    fs::remove_file(path).map_err(|e| e.to_string())
88}
89
90/// Copy file to `dst` (parent dirs created).
91pub fn copy_file(src: &Path, dst: &Path) -> Result<(), String> {
92    if let Some(parent) = dst.parent() {
93        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
94    }
95    fs::copy(src, dst).map_err(|e| e.to_string())?;
96    Ok(())
97}
98
99/// Rename or move `from` → `to` (same filesystem).
100pub fn rename(from: &Path, to: &Path) -> Result<(), String> {
101    if let Some(parent) = to.parent() {
102        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
103    }
104    fs::rename(from, to).map_err(|e| e.to_string())
105}
106
107/// Top-level directory entries (names only), sorted, capped at `max_entries`.
108pub fn list_dir_names(path: &Path, max_entries: usize) -> Result<Vec<String>, String> {
109    if !path.is_dir() {
110        return Err("path is not a directory".into());
111    }
112    let mut names = Vec::new();
113    for e in fs::read_dir(path).map_err(|e| e.to_string())? {
114        let e = e.map_err(|e| e.to_string())?;
115        names.push(e.file_name().to_string_lossy().into_owned());
116    }
117    names.sort();
118    if names.len() > max_entries {
119        names.truncate(max_entries);
120    }
121    Ok(names)
122}
123
124/// One-level listing with `is_dir` hint: `(name, is_directory)`.
125pub fn list_dir_entries(path: &Path, max_entries: usize) -> Result<Vec<(String, bool)>, String> {
126    if !path.is_dir() {
127        return Err("path is not a directory".into());
128    }
129    let mut out = Vec::new();
130    for e in fs::read_dir(path).map_err(|e| e.to_string())? {
131        let e = e.map_err(|e| e.to_string())?;
132        let p = e.path();
133        let is_d = p.is_dir();
134        out.push((e.file_name().to_string_lossy().into_owned(), is_d));
135    }
136    out.sort_by(|a, b| a.0.cmp(&b.0));
137    out.truncate(max_entries);
138    Ok(out)
139}
140
141/// Canonicalize if possible; on error returns the original path as [`PathBuf`].
142pub fn try_canonicalize(path: &Path) -> PathBuf {
143    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use std::io::Write;
150
151    #[test]
152    fn write_read_roundtrip() {
153        let dir = tempfile::tempdir().unwrap();
154        let p = dir.path().join("a.txt");
155        write_file(&p, "hello").unwrap();
156        assert_eq!(read_file_bounded(&p, 1024).unwrap(), "hello");
157    }
158
159    #[test]
160    fn read_too_large_errors() {
161        let dir = tempfile::tempdir().unwrap();
162        let p = dir.path().join("big.txt");
163        let mut f = fs::File::create(&p).unwrap();
164        f.write_all(&[b'x'; 100]).unwrap();
165        assert!(read_file_bounded(&p, 50).is_err());
166    }
167
168    #[test]
169    fn list_dir_names_sorted() {
170        let dir = tempfile::tempdir().unwrap();
171        fs::write(dir.path().join("b"), "").unwrap();
172        fs::write(dir.path().join("a"), "").unwrap();
173        let n = list_dir_names(dir.path(), 10).unwrap();
174        assert_eq!(n, vec!["a".to_string(), "b".to_string()]);
175    }
176}