//! Pod 視点のファイルシステム操作。 //! //! `ScopedFs` の上に「Pod が読み取りたい / 列挙したい」操作を集約する軽い wrapper。 //! //! - `ReadRequirement` と `render_auto_read` — compact worker が `mark_read_required` //! で nominate したファイルを再読し、`[Auto-read file: ...]` system message に //! 変換する経路。`Pod::compact` から呼ばれる。 //! - `slice_lines` — 行 offset / limit でテキストを切り出す純粋ヘルパ。 //! compact tool 側の `mark_read_required` でも使用。 //! - `list_file_completions` — TUI 補完用、prefix マッチでファイル候補を列挙する経路。 //! IPC `Method::ListCompletions` 経由で呼ばれる前提(Phase 2 で接続)。 use std::path::{Path, PathBuf}; use llm_worker::Item; use manifest::Scope; use tools::scoped_fs::first_symlink; use tools::{ScopedFs, ToolsError}; use tracing::warn; /// 補完候補1件の最大数。`list_file_completions` がこの値を超えたら打ち切り。 const COMPLETION_LIMIT: usize = 100; /// submit-time directory FileRef の shallow listing で返す最大 entry 数。 /// TUI completion と同じ浅い一覧という意味論に揃えるため、同じ上限を使う。 const DIR_FILE_REF_ENTRY_LIMIT: usize = COMPLETION_LIMIT; /// Compact worker が `mark_read_required` で nominate した「次セッション開始時に /// 自動で再読すべきファイル」のエントリ。 #[derive(Debug, Clone)] pub struct ReadRequirement { pub path: PathBuf, /// 0-based line offset. `None` means from the start of the file. pub offset: Option, /// Maximum number of lines. `None` means to the end of the file. pub limit: Option, } /// Pod から見えるファイルシステム操作の入口。Clone は cheap(`ScopedFs` 内 `Arc`)。 #[derive(Debug, Clone)] pub struct PodFsView { fs: ScopedFs, } /// `list_file_completions` が返す候補1件。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct FileCandidate { /// 入力 prefix と整合する形のパス(prefix が absolute なら absolute、 /// relative なら pwd 相対)。 pub path: String, pub is_dir: bool, } /// `resolve_file_ref` の失敗理由。Pod 側で Alert に振り分けるために /// ScopedFs / 内部判定の両方を区別できるよう保持する。 #[derive(Debug)] pub enum ResolveError { /// Path resolution / scope check failed via `ScopedFs`. Fs(ToolsError), /// File contents are not valid UTF-8 (binary / non-text). Binary { path: PathBuf }, } impl std::fmt::Display for ResolveError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ResolveError::Fs(e) => write!(f, "{e}"), ResolveError::Binary { path } => { write!(f, "file is not valid UTF-8 text: {}", path.display()) } } } } impl std::error::Error for ResolveError {} impl PodFsView { pub fn new(fs: ScopedFs) -> Self { Self { fs } } pub fn fs(&self) -> &ScopedFs { &self.fs } /// `requirements` の各エントリを `ScopedFs` 経由で再読し、 /// `[Auto-read file: :]\n` 形式の system message に変換する。 /// 読み取り失敗(NotFound / OutOfScope 等)は warn で記録してスキップする /// — compact 全体を落とさないため。 pub fn render_auto_read(&self, requirements: &[ReadRequirement]) -> Vec { let mut out = Vec::with_capacity(requirements.len()); for req in requirements { match self.fs.read_bytes(&req.path) { Ok(bytes) => { let text = String::from_utf8_lossy(&bytes).into_owned(); let body = slice_lines(&text, req.offset.unwrap_or(0), req.limit); let range = format_range(req.offset, req.limit); out.push(Item::system_message(format!( "[Auto-read file: {}{range}]\n{body}", req.path.display() ))); } Err(e) => { warn!( path = %req.path.display(), error = %e, "auto-read target could not be read; skipping", ); } } } out } /// `path` を ScopedFs 経由で解決し、submit 時の `Segment::FileRef` /// attachment 用 system message を返す。 /// /// - `path` は relative なら pwd 相対、absolute なら absolute として解釈 /// - 通常ディレクトリは浅い entry listing として `[Dir: ]\n` に展開する /// - ディレクトリ listing は hidden / gitignore を特別扱いせず、scope 上 readable な /// 直下 entry だけを最大 `DIR_FILE_REF_ENTRY_LIMIT` 件返す /// - ファイル本文またはディレクトリ listing 本文が `max_bytes` を超える場合は切り詰める /// - 非 UTF-8 (バイナリ) は `ResolveError::Binary` で拒否 /// - スコープ外 / NotFound / symlink directory 等は `ResolveError::Fs` で返す pub fn resolve_file_ref(&self, path: &str, max_bytes: usize) -> Result { let p = Path::new(path); let abs = if p.is_absolute() { p.to_path_buf() } else { self.fs.pwd().join(p) }; // 通常ディレクトリだけを FileRef listing として扱う。symlink を含むパスは // `ScopedFs::read_bytes` に委ね、既存の symlink 診断 // (`SymlinkTargetIsDirectory` / `SymlinkOutOfScope` 等) を保つ。 if first_symlink(&abs).is_none() { let scope = self.fs.scope(); if !scope.is_readable(&abs) { return Err(ResolveError::Fs(ToolsError::OutOfScope(abs))); } let meta = metadata_for_file_ref(&abs).map_err(ResolveError::Fs)?; if meta.is_dir() { return render_dir_file_ref(path, &abs, max_bytes, scope.as_ref()); } } let bytes = self.fs.read_bytes(&abs).map_err(ResolveError::Fs)?; let total = bytes.len(); let (body_bytes, truncated) = if total > max_bytes { (&bytes[..max_bytes], true) } else { (bytes.as_slice(), false) }; let body = std::str::from_utf8(body_bytes) .map_err(|_| ResolveError::Binary { path: abs.clone() })?; let mut text = format!("[File: {path}]\n{body}"); if truncated { text.push_str(&format!( "\n[...truncated, {total} bytes total — use read_file for the rest]" )); } Ok(Item::system_message(text)) } /// `prefix` にマッチするファイル / ディレクトリを scope 内で浅く列挙する。 /// /// - `prefix` が空 or `pwd` 相対のときは pwd 直下を見る /// - `prefix` が末尾 `/` のときはそのディレクトリ直下を全列挙 /// - 末尾が名前部分のときは、その名前を starts_with でフィルタ /// - scope 上 readable なエントリのみ返す /// - ディレクトリ → ファイル の順、各グループ内は名前昇順 /// - 上限 `COMPLETION_LIMIT` 件で打ち切り(深い列挙はしない) pub fn list_file_completions(&self, prefix: &str) -> Vec { let pwd = self.fs.pwd(); let scope = self.fs.scope(); let (dir, name_prefix, is_absolute) = split_prefix(prefix, pwd); let read_dir = match std::fs::read_dir(&dir) { Ok(rd) => rd, Err(_) => return Vec::new(), }; let mut out = Vec::new(); for entry in read_dir.flatten() { let file_name = entry.file_name(); let name = file_name.to_string_lossy(); if !name.starts_with(&name_prefix) { continue; } let path = entry.path(); if !scope.is_readable(&path) { continue; } let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); let display = if is_absolute { path.display().to_string() } else { path.strip_prefix(pwd) .map(|p| p.display().to_string()) .unwrap_or_else(|_| path.display().to_string()) }; out.push(FileCandidate { path: display, is_dir, }); } out.sort_by(|a, b| match (a.is_dir, b.is_dir) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, _ => a.path.cmp(&b.path), }); out.truncate(COMPLETION_LIMIT); out } } /// `text` の `offset` 行目から `limit` 行(None なら末尾まで)を、元の改行で繋いで返す。 pub fn slice_lines(text: &str, offset: usize, limit: Option) -> String { let lines: Vec<&str> = text.lines().collect(); let start = offset.min(lines.len()); let end = limit .map(|n| start.saturating_add(n).min(lines.len())) .unwrap_or(lines.len()); lines[start..end].join("\n") } #[derive(Debug, Clone, PartialEq, Eq)] struct DirListingEntry { display: String, kind_rank: u8, } fn metadata_for_file_ref(path: &Path) -> Result { std::fs::metadata(path).map_err(|e| match e.kind() { std::io::ErrorKind::NotFound => ToolsError::NotFound(path.to_path_buf()), _ => ToolsError::io(path, e), }) } fn render_dir_file_ref( original_path: &str, abs: &Path, max_bytes: usize, scope: &Scope, ) -> Result { let read_dir = std::fs::read_dir(abs).map_err(|e| ResolveError::Fs(ToolsError::io(abs, e)))?; let mut entries = Vec::new(); for entry in read_dir { let entry = entry.map_err(|e| ResolveError::Fs(ToolsError::io(abs, e)))?; let path = entry.path(); if !scope.is_readable(&path) { continue; } let file_type = match entry.file_type() { Ok(ft) => ft, Err(e) => return Err(ResolveError::Fs(ToolsError::io(&path, e))), }; let mut display = entry.file_name().to_string_lossy().into_owned(); let kind_rank = if file_type.is_dir() { display.push('/'); 0 } else if file_type.is_symlink() { display.push('@'); 1 } else { 2 }; entries.push(DirListingEntry { display, kind_rank }); } entries.sort_by(|a, b| { a.kind_rank .cmp(&b.kind_rank) .then_with(|| a.display.cmp(&b.display)) }); let total_entries = entries.len(); let entry_truncated = total_entries > DIR_FILE_REF_ENTRY_LIMIT; let body = if total_entries == 0 { "(empty directory)".to_string() } else { entries .iter() .take(DIR_FILE_REF_ENTRY_LIMIT) .map(|e| e.display.as_str()) .collect::>() .join("\n") }; let body_total_bytes = body.len(); let (body, byte_truncated) = truncate_utf8_bytes(&body, max_bytes); let mut text = format!("[Dir: {original_path}]\n{body}"); if entry_truncated || byte_truncated { text.push('\n'); text.push_str(&dir_listing_truncation_hint( entry_truncated, byte_truncated, total_entries, body_total_bytes, )); } Ok(Item::system_message(text)) } fn truncate_utf8_bytes(s: &str, max_bytes: usize) -> (&str, bool) { if s.len() <= max_bytes { return (s, false); } let mut end = max_bytes; while !s.is_char_boundary(end) { end -= 1; } (&s[..end], true) } fn dir_listing_truncation_hint( entry_truncated: bool, byte_truncated: bool, total_entries: usize, body_total_bytes: usize, ) -> String { match (entry_truncated, byte_truncated) { (true, true) => format!( "[...truncated, {total_entries} readable entries total; first {DIR_FILE_REF_ENTRY_LIMIT} entries were {body_total_bytes} bytes before byte cap — use Glob for more]" ), (true, false) => { format!("[...truncated, {total_entries} readable entries total — use Glob for more]") } (false, true) => { format!("[...truncated, {body_total_bytes} bytes total — use Glob or Read for more]") } (false, false) => String::new(), } } fn format_range(offset: Option, limit: Option) -> String { match (offset, limit) { (None, None) => String::new(), (Some(off), None) => format!(":{}-", off + 1), (None, Some(lim)) => format!(":1-{lim}"), (Some(off), Some(lim)) => format!(":{}-{}", off + 1, off.saturating_add(lim)), } } fn split_prefix(prefix: &str, pwd: &Path) -> (PathBuf, String, bool) { let is_absolute = Path::new(prefix).is_absolute(); let p = Path::new(prefix); let (parent, name) = if prefix.is_empty() || prefix.ends_with('/') { (p.to_path_buf(), String::new()) } else { let parent = p.parent().map(|p| p.to_path_buf()).unwrap_or_default(); let name = p .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); (parent, name) }; let dir = if is_absolute { parent } else if parent.as_os_str().is_empty() { pwd.to_path_buf() } else { pwd.join(parent) }; (dir, name, is_absolute) } #[cfg(test)] mod tests { use super::*; use llm_worker::ContentPart; use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; use tempfile::TempDir; fn fs_for(dir: &TempDir) -> ScopedFs { ScopedFs::new( Scope::writable(dir.path()).unwrap(), dir.path().to_path_buf(), ) } fn touch(path: &Path, content: &str) { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).unwrap(); } std::fs::write(path, content).unwrap(); } fn system_text(item: &Item) -> &str { let Item::Message { content, .. } = item else { panic!("expected message item"); }; let Some(ContentPart::Text { text }) = content.first() else { panic!("expected text content"); }; text } #[test] fn slice_lines_handles_offset_and_limit() { let text = "a\nb\nc\nd"; assert_eq!(slice_lines(text, 0, None), "a\nb\nc\nd"); assert_eq!(slice_lines(text, 1, Some(2)), "b\nc"); assert_eq!(slice_lines(text, 10, None), ""); } #[test] fn render_auto_read_emits_system_messages_with_range_label() { let dir = TempDir::new().unwrap(); let file = dir.path().join("hello.txt"); std::fs::write(&file, "alpha\nbeta\ngamma\n").unwrap(); let view = PodFsView::new(fs_for(&dir)); let items = view.render_auto_read(&[ReadRequirement { path: file.clone(), offset: Some(1), limit: Some(1), }]); assert_eq!(items.len(), 1); let rendered = format!("{:?}", items[0]); assert!(rendered.contains("Auto-read file")); assert!(rendered.contains(":2-2")); assert!(rendered.contains("beta")); assert!(!rendered.contains("alpha")); } #[test] fn resolve_file_ref_emits_system_message_with_path_header() { let dir = TempDir::new().unwrap(); std::fs::write(dir.path().join("hello.txt"), "hello world").unwrap(); let view = PodFsView::new(fs_for(&dir)); let item = view.resolve_file_ref("hello.txt", 1024).unwrap(); let text = format!("{item:?}"); assert!(text.contains("[File: hello.txt]")); assert!(text.contains("hello world")); assert!(!text.contains("truncated")); } #[test] fn resolve_file_ref_truncates_with_hint_when_over_cap() { let dir = TempDir::new().unwrap(); let body = "x".repeat(2048); std::fs::write(dir.path().join("big.txt"), &body).unwrap(); let view = PodFsView::new(fs_for(&dir)); let item = view.resolve_file_ref("big.txt", 256).unwrap(); let text = format!("{item:?}"); assert!(text.contains("[File: big.txt]")); assert!(text.contains("truncated")); assert!(text.contains("2048 bytes total")); } #[test] fn resolve_file_ref_lists_directory_shallow_entries() { let dir = TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join("docs/sub")).unwrap(); touch(&dir.path().join("docs/.hidden"), "hidden"); touch(&dir.path().join("docs/.gitignore"), "ignored.txt\n"); touch( &dir.path().join("docs/ignored.txt"), "not ignored for FileRef", ); let view = PodFsView::new(fs_for(&dir)); let item = view.resolve_file_ref("docs", 4096).unwrap(); let text = system_text(&item); assert!(text.starts_with("[Dir: docs]\n")); assert!(text.contains("sub/")); assert!(text.contains(".hidden")); assert!(text.contains(".gitignore")); assert!(text.contains("ignored.txt")); let sub_pos = text.find("sub/").unwrap(); let hidden_pos = text.find(".hidden").unwrap(); assert!( sub_pos < hidden_pos, "directories should sort before files:\n{text}" ); } #[test] fn resolve_file_ref_directory_listing_filters_unreadable_entries() { let dir = TempDir::new().unwrap(); let docs = dir.path().join("docs"); let secret = docs.join("secret"); std::fs::create_dir_all(&secret).unwrap(); touch(&docs.join("visible.txt"), "ok"); touch(&secret.join("hidden.txt"), "nope"); let cfg = ScopeConfig { allow: vec![ScopeRule { target: dir.path().to_path_buf(), permission: Permission::Write, recursive: true, }], deny: vec![ScopeRule { target: secret.clone(), permission: Permission::Read, recursive: true, }], }; let scope = Scope::from_config(&cfg).unwrap(); let fs = ScopedFs::new(scope, dir.path().to_path_buf()); let view = PodFsView::new(fs); let item = view.resolve_file_ref("docs", 4096).unwrap(); let text = system_text(&item); assert!(text.contains("visible.txt")); assert!(!text.contains("secret")); assert!(!text.contains("hidden.txt")); } #[test] fn resolve_file_ref_directory_listing_uses_upload_byte_cap() { let dir = TempDir::new().unwrap(); std::fs::create_dir(dir.path().join("docs")).unwrap(); touch(&dir.path().join("docs/very-long-file-name.txt"), ""); touch(&dir.path().join("docs/another-long-file-name.txt"), ""); let view = PodFsView::new(fs_for(&dir)); let item = view.resolve_file_ref("docs", 10).unwrap(); let text = system_text(&item); assert!(text.starts_with("[Dir: docs]\n")); assert!(text.contains("truncated")); assert!(text.contains("bytes total")); assert!(text.contains("use Glob or Read for more")); } #[test] fn resolve_file_ref_directory_listing_uses_completion_entry_limit() { let dir = TempDir::new().unwrap(); std::fs::create_dir(dir.path().join("docs")).unwrap(); for i in 0..(DIR_FILE_REF_ENTRY_LIMIT + 5) { touch(&dir.path().join(format!("docs/file-{i:03}.txt")), ""); } let view = PodFsView::new(fs_for(&dir)); let item = view.resolve_file_ref("docs", 4096).unwrap(); let text = system_text(&item); assert!(text.contains("105 readable entries total")); assert!(text.contains("file-099.txt")); assert!(!text.contains("file-100.txt")); assert!(text.contains("use Glob for more")); } #[cfg(unix)] #[test] fn resolve_file_ref_directory_listing_marks_readable_symlink_entries() { use std::os::unix::fs::symlink; let dir = TempDir::new().unwrap(); std::fs::create_dir(dir.path().join("docs")).unwrap(); touch(&dir.path().join("docs/target.txt"), "target"); symlink("target.txt", dir.path().join("docs/link.txt")).unwrap(); let view = PodFsView::new(fs_for(&dir)); let item = view.resolve_file_ref("docs", 4096).unwrap(); let text = system_text(&item); assert!(text.contains("link.txt@")); } #[test] fn resolve_file_ref_rejects_binary_with_binary_error() { let dir = TempDir::new().unwrap(); std::fs::write(dir.path().join("blob.bin"), [0xff, 0xfe, 0x00, 0x80]).unwrap(); let view = PodFsView::new(fs_for(&dir)); let err = view.resolve_file_ref("blob.bin", 1024).unwrap_err(); assert!(matches!(err, ResolveError::Binary { .. })); } #[test] fn resolve_file_ref_returns_fs_error_for_out_of_scope() { let outer = TempDir::new().unwrap(); let inner = outer.path().join("scoped"); std::fs::create_dir(&inner).unwrap(); std::fs::write(outer.path().join("secret.txt"), "nope").unwrap(); let scope = Scope::writable(&inner).unwrap(); let fs = ScopedFs::new(scope, inner.clone()); let view = PodFsView::new(fs); // Absolute path outside of scope. let outside = outer.path().join("secret.txt"); let err = view .resolve_file_ref(outside.to_str().unwrap(), 1024) .unwrap_err(); assert!(matches!(err, ResolveError::Fs(_))); } #[test] fn render_auto_read_skips_unreadable_targets() { let dir = TempDir::new().unwrap(); let view = PodFsView::new(fs_for(&dir)); let items = view.render_auto_read(&[ReadRequirement { path: dir.path().join("missing.txt"), offset: None, limit: None, }]); assert!(items.is_empty()); } #[test] fn list_file_completions_lists_pwd_when_prefix_empty() { let dir = TempDir::new().unwrap(); touch(&dir.path().join("alpha.rs"), ""); touch(&dir.path().join("beta.rs"), ""); std::fs::create_dir(dir.path().join("subdir")).unwrap(); let view = PodFsView::new(fs_for(&dir)); let cands = view.list_file_completions(""); // ディレクトリ first let names: Vec<&str> = cands.iter().map(|c| c.path.as_str()).collect(); assert_eq!(names, vec!["subdir", "alpha.rs", "beta.rs"]); assert!(cands[0].is_dir); } #[test] fn list_file_completions_filters_by_name_prefix() { let dir = TempDir::new().unwrap(); touch(&dir.path().join("alpha.rs"), ""); touch(&dir.path().join("beta.rs"), ""); let view = PodFsView::new(fs_for(&dir)); let cands = view.list_file_completions("al"); assert_eq!(cands.len(), 1); assert_eq!(cands[0].path, "alpha.rs"); } #[test] fn list_file_completions_descends_into_subdir_with_trailing_slash() { let dir = TempDir::new().unwrap(); touch(&dir.path().join("sub/x.rs"), ""); touch(&dir.path().join("sub/y.rs"), ""); let view = PodFsView::new(fs_for(&dir)); let cands = view.list_file_completions("sub/"); let names: Vec<&str> = cands.iter().map(|c| c.path.as_str()).collect(); assert_eq!(names, vec!["sub/x.rs", "sub/y.rs"]); } #[test] fn list_file_completions_filters_out_non_readable_under_scope() { let dir = TempDir::new().unwrap(); let secret = dir.path().join("secret"); std::fs::create_dir(&secret).unwrap(); touch(&dir.path().join("visible.rs"), ""); touch(&secret.join("hidden.rs"), ""); let cfg = ScopeConfig { allow: vec![ScopeRule { target: dir.path().to_path_buf(), permission: Permission::Write, recursive: true, }], deny: vec![ScopeRule { target: secret.clone(), permission: Permission::Read, recursive: true, }], }; let scope = Scope::from_config(&cfg).unwrap(); let fs = ScopedFs::new(scope, dir.path().to_path_buf()); let view = PodFsView::new(fs); let cands = view.list_file_completions(""); let names: Vec<&str> = cands.iter().map(|c| c.path.as_str()).collect(); assert!(names.contains(&"visible.rs")); assert!(!names.contains(&"secret")); } #[test] fn list_file_completions_supports_absolute_prefix() { let dir = TempDir::new().unwrap(); touch(&dir.path().join("a.rs"), ""); let view = PodFsView::new(fs_for(&dir)); let prefix = format!("{}/", dir.path().display()); let cands = view.list_file_completions(&prefix); assert_eq!(cands.len(), 1); assert!(cands[0].path.starts_with('/')); assert!(cands[0].path.ends_with("a.rs")); } }