Skip to main content

anda_engine/extension/
fs.rs

1//! Workspace-scoped filesystem tool support.
2//!
3//! This module contains shared path resolution, text decoding/encoding, size
4//! limits, and atomic write helpers used by the read, write, search, and edit
5//! filesystem tools. Public tool structs are re-exported from the submodules.
6//!
7//! All four tools report the same capability group via [`fs_tool_group_info`],
8//! so the discovery layer presents them to the model as one workspace bundle.
9
10use anda_core::{
11    BoxError, RequestMeta, ToolGroupInfo, platform_text_encoding, text_encoding_for_label,
12    text_encoding_label, text_from_bytes_with_encoding,
13};
14use encoding_rs::Encoding;
15use std::{
16    ffi::OsString,
17    fmt,
18    fs::{Metadata, Permissions},
19    path::{Component, Path, PathBuf},
20};
21use tokio::io::AsyncWriteExt;
22
23mod edit;
24mod read;
25mod search;
26mod write;
27
28pub use edit::*;
29pub use read::*;
30pub use search::*;
31pub use write::*;
32
33/// Stable id of the filesystem workspace capability group.
34pub const FS_TOOL_GROUP_ID: &str = "fs_workspace";
35
36/// Returns the shared [`ToolGroupInfo`] for the filesystem workspace tools.
37///
38/// Every `read_file` / `search_file` / `edit_file` / `write_file` tool reports
39/// this so the registry presents them as one bundle. The registry fills in the
40/// member list from the tools actually registered.
41pub fn fs_tool_group_info() -> ToolGroupInfo {
42    ToolGroupInfo {
43        id: FS_TOOL_GROUP_ID.to_string(),
44        title: "Filesystem workspace".to_string(),
45        description: "Read, search, edit, and write files within the agent's sandboxed workspace directories.".to_string(),
46        instructions: Some(
47            "These tools share one set of sandboxed workspace directories; paths are workspace-relative and access outside the workspace is denied. Typical flow: use `search_file` to locate content and `read_file` to inspect it (paging large files with offset/limit), then `edit_file` for targeted in-place changes or `write_file` to create or replace a whole file.".to_string(),
48        ),
49    }
50}
51
52pub(crate) const MAX_FILE_SIZE_BYTES: u64 = 10 * 1024 * 1024;
53
54/// Maximum bytes of file content returned inline in a tool response. Larger
55/// content is truncated so a single read cannot flood the model context.
56pub(crate) const MAX_INLINE_CONTENT_BYTES: usize = 256 * 1024;
57
58pub(crate) const UTF8_ENCODING: &str = "utf8";
59pub(crate) const BASE64_ENCODING: &str = "base64";
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub(crate) struct DecodedFileText {
63    pub(crate) text: String,
64    pub(crate) encoding: String,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub(crate) enum FileTextEncodeError {
69    UnsupportedEncoding,
70    UnmappableCharacters,
71}
72
73impl fmt::Display for FileTextEncodeError {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        match self {
76            Self::UnsupportedEncoding => f.write_str("unsupported text encoding"),
77            Self::UnmappableCharacters => f.write_str(
78                "content contains characters not representable in the requested encoding",
79            ),
80        }
81    }
82}
83
84impl std::error::Error for FileTextEncodeError {}
85
86#[derive(Debug, Clone)]
87pub(crate) struct ResolvedFilePath {
88    pub(crate) workspace: PathBuf,
89    pub(crate) path: PathBuf,
90}
91
92pub(crate) fn normalize_workspaces<I>(workspaces: I) -> Vec<PathBuf>
93where
94    I: IntoIterator<Item = PathBuf>,
95{
96    let mut normalized = Vec::new();
97    for workspace in workspaces {
98        push_workspace(&mut normalized, workspace);
99    }
100
101    normalized
102}
103
104pub(crate) fn tool_workspaces(meta: &RequestMeta, defaults: &[PathBuf]) -> Vec<PathBuf> {
105    let mut workspaces = Vec::new();
106
107    if let Some(workspace) = meta.get_extra_as::<PathBuf>("workspace") {
108        push_workspace(&mut workspaces, workspace);
109    } else if let Some(extra_workspaces) = meta.get_extra_as::<Vec<PathBuf>>("workspace") {
110        for workspace in extra_workspaces {
111            push_workspace(&mut workspaces, workspace);
112        }
113    }
114
115    if let Some(workspace) = meta.get_extra_as::<PathBuf>("workspaces") {
116        push_workspace(&mut workspaces, workspace);
117    } else if let Some(extra_workspaces) = meta.get_extra_as::<Vec<PathBuf>>("workspaces") {
118        for workspace in extra_workspaces {
119            push_workspace(&mut workspaces, workspace);
120        }
121    }
122
123    for workspace in defaults {
124        push_workspace(&mut workspaces, workspace.clone());
125    }
126
127    workspaces
128}
129
130pub(crate) fn format_workspaces(workspaces: &[PathBuf]) -> String {
131    if workspaces.is_empty() {
132        return "<none>".to_string();
133    }
134
135    workspaces
136        .iter()
137        .map(|workspace| workspace.display().to_string())
138        .collect::<Vec<_>>()
139        .join(", ")
140}
141
142fn push_workspace(workspaces: &mut Vec<PathBuf>, workspace: PathBuf) {
143    if workspace.as_os_str().is_empty() {
144        return;
145    }
146
147    if !workspaces.iter().any(|existing| existing == &workspace) {
148        workspaces.push(workspace);
149    }
150}
151
152pub(crate) async fn resolve_read_path_in_workspaces(
153    workspaces: &[PathBuf],
154    user_path: &str,
155) -> Result<ResolvedFilePath, BoxError> {
156    let mut errors = Vec::new();
157
158    for workspace in workspaces {
159        match resolve_read_path(workspace, user_path).await {
160            Ok(path) => {
161                return Ok(ResolvedFilePath {
162                    workspace: workspace.clone(),
163                    path,
164                });
165            }
166            Err(err) => errors.push(format!("{}: {err}", workspace.display())),
167        }
168    }
169
170    Err(workspace_access_error(
171        "Path",
172        "requested_path",
173        user_path,
174        workspaces,
175        errors,
176    ))
177}
178
179pub(crate) async fn resolve_write_path_in_workspaces(
180    workspaces: &[PathBuf],
181    user_path: &str,
182) -> Result<ResolvedFilePath, BoxError> {
183    let requested_path = Path::new(user_path);
184
185    if requested_path.is_relative() {
186        for workspace in workspaces {
187            let candidate_path = workspace.join(requested_path);
188            match tokio::fs::symlink_metadata(&candidate_path).await {
189                Ok(_) => {
190                    let path = resolve_write_path(workspace, user_path).await?;
191                    return Ok(ResolvedFilePath {
192                        workspace: workspace.clone(),
193                        path,
194                    });
195                }
196                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
197                Err(err) => {
198                    return Err(format!(
199                        "Failed to inspect file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
200                        workspace.display(),
201                        user_path,
202                        candidate_path.display()
203                    )
204                    .into());
205                }
206            }
207        }
208    }
209
210    let mut errors = Vec::new();
211    for workspace in workspaces {
212        match resolve_write_path(workspace, user_path).await {
213            Ok(path) => {
214                return Ok(ResolvedFilePath {
215                    workspace: workspace.clone(),
216                    path,
217                });
218            }
219            Err(err) => errors.push(format!("{}: {err}", workspace.display())),
220        }
221    }
222
223    Err(workspace_access_error(
224        "Path",
225        "requested_path",
226        user_path,
227        workspaces,
228        errors,
229    ))
230}
231
232pub(crate) fn workspace_access_error(
233    subject: &str,
234    request_label: &str,
235    requested_value: &str,
236    workspaces: &[PathBuf],
237    errors: Vec<String>,
238) -> BoxError {
239    let details = if errors.is_empty() {
240        String::new()
241    } else {
242        format!("; errors: {}", errors.join("; "))
243    };
244
245    format!(
246        "{subject} is not accessible from any configured workspace ({request_label}: {}, workspaces: [{}]){}",
247        requested_value,
248        format_workspaces(workspaces),
249        details
250    )
251    .into()
252}
253
254/// Resolves an existing read target reachable from the workspace namespace.
255pub async fn resolve_read_path(workspace: &Path, user_path: &str) -> Result<PathBuf, BoxError> {
256    let resolved_workspace = resolve_workspace_path(workspace).await?;
257    let requested_path = Path::new(user_path);
258    let path = workspace.join(requested_path);
259
260    if !path_contains_parent_reference(requested_path) {
261        ensure_path_in_workspace_namespace(workspace, &resolved_workspace, &path)?;
262
263        return tokio::fs::canonicalize(&path)
264            .await
265            .map_err(|err| {
266                format!(
267                    "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
268                    workspace.display(),
269                    requested_path.display(),
270                    path.display()
271                )
272                .into()
273            });
274    }
275
276    let resolved_path = tokio::fs::canonicalize(&path)
277        .await
278        .map_err(|err| {
279            format!(
280                "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
281                workspace.display(),
282                requested_path.display(),
283                path.display()
284            )
285        })?;
286
287    ensure_path_in_workspace(&resolved_workspace, &resolved_path)?;
288
289    Ok(resolved_path)
290}
291
292/// Resolves a write target inside the workspace, even when the destination does not yet exist.
293pub async fn resolve_write_path(workspace: &Path, user_path: &str) -> Result<PathBuf, BoxError> {
294    let resolved_workspace = resolve_workspace_path(workspace).await?;
295    let path = workspace.join(user_path);
296
297    match tokio::fs::symlink_metadata(&path).await {
298        Ok(meta) => {
299            if meta.file_type().is_symlink() {
300                return Err(format!(
301                    "Writing to symbolic links is not allowed (workspace: {}, path: {})",
302                    workspace.display(),
303                    path.display()
304                )
305                .into());
306            }
307
308            let resolved_path = tokio::fs::canonicalize(&path)
309                .await
310                .map_err(|err| {
311                    format!(
312                        "Failed to resolve file path (workspace: {}, requested_path: {}, candidate_path: {}): {err}",
313                        workspace.display(),
314                        user_path,
315                        path.display()
316                    )
317                })?;
318            ensure_path_in_workspace(&resolved_workspace, &resolved_path)?;
319
320            Ok(resolved_path)
321        }
322        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
323            let (existing_ancestor, missing_components) = nearest_existing_ancestor(&path).await?;
324            let resolved_ancestor = tokio::fs::canonicalize(&existing_ancestor)
325                .await
326                .map_err(|err| {
327                    format!(
328                        "Failed to resolve file path ancestor (workspace: {}, requested_path: {}, ancestor_path: {}): {err}",
329                        workspace.display(),
330                        user_path,
331                        existing_ancestor.display()
332                    )
333                })?;
334            ensure_path_in_workspace(&resolved_workspace, &resolved_ancestor)?;
335
336            Ok(missing_components
337                .into_iter()
338                .rev()
339                .fold(resolved_ancestor, |acc, component| acc.join(component)))
340        }
341        Err(err) => Err(format!(
342            "Failed to inspect file path (workspace: {}, path: {}): {err}",
343            workspace.display(),
344            path.display()
345        )
346        .into()),
347    }
348}
349
350pub(crate) async fn resolve_workspace_path(workspace: &Path) -> Result<PathBuf, BoxError> {
351    tokio::fs::canonicalize(workspace).await.map_err(|err| {
352        format!(
353            "Failed to resolve workspace path (workspace: {}): {err}",
354            workspace.display()
355        )
356        .into()
357    })
358}
359
360pub(crate) fn ensure_path_in_workspace(
361    resolved_workspace: &Path,
362    resolved_path: &Path,
363) -> Result<(), BoxError> {
364    if !resolved_path.starts_with(resolved_workspace) {
365        return Err(format!(
366            "Access to paths outside the workspace is not allowed (resolved_workspace: {}, resolved_path: {})",
367            resolved_workspace.display(),
368            resolved_path.display()
369        )
370        .into());
371    }
372
373    Ok(())
374}
375
376/// Returns true when the requested path contains a parent directory traversal.
377pub(crate) fn path_contains_parent_reference(path: &Path) -> bool {
378    path.components()
379        .any(|component| matches!(component, Component::ParentDir))
380}
381
382/// Ensures the requested path stays within the workspace namespace before following symlinks.
383pub(crate) fn ensure_path_in_workspace_namespace(
384    workspace: &Path,
385    resolved_workspace: &Path,
386    requested_path: &Path,
387) -> Result<(), BoxError> {
388    if requested_path.starts_with(workspace) || requested_path.starts_with(resolved_workspace) {
389        return Ok(());
390    }
391
392    Err(format!(
393        "Access to paths outside the workspace is not allowed (workspace: {}, resolved_workspace: {}, requested_path: {})",
394        workspace.display(),
395        resolved_workspace.display(),
396        requested_path.display()
397    )
398    .into())
399}
400
401/// Returns the default encoding used for file writes.
402pub(crate) fn default_write_encoding() -> String {
403    UTF8_ENCODING.to_string()
404}
405
406pub(crate) fn decode_file_text(bytes: Vec<u8>) -> Result<DecodedFileText, Vec<u8>> {
407    decode_file_text_with_fallback(bytes, platform_text_encoding())
408}
409
410fn decode_file_text_with_fallback(
411    bytes: Vec<u8>,
412    fallback_encoding: Option<&'static Encoding>,
413) -> Result<DecodedFileText, Vec<u8>> {
414    // Take ownership on success so valid UTF-8 content is not copied.
415    let bytes = match String::from_utf8(bytes) {
416        Ok(text) => {
417            return Ok(DecodedFileText {
418                text,
419                encoding: UTF8_ENCODING.to_string(),
420            });
421        }
422        Err(err) => err.into_bytes(),
423    };
424
425    let Some(encoding) = fallback_encoding else {
426        return Err(bytes);
427    };
428    if encoding.name() == "UTF-8" {
429        return Err(bytes);
430    }
431
432    let text = match text_from_bytes_with_encoding(&bytes, Some(encoding)) {
433        Some(text) => text.into_owned(),
434        None => return Err(bytes),
435    };
436    if !is_text_like(&text) {
437        return Err(bytes);
438    }
439
440    Ok(DecodedFileText {
441        text,
442        encoding: text_encoding_label(encoding),
443    })
444}
445
446pub(crate) fn encode_file_text(
447    content: &str,
448    encoding_label: &str,
449) -> Result<Vec<u8>, FileTextEncodeError> {
450    let encoding =
451        text_encoding_for_label(encoding_label).ok_or(FileTextEncodeError::UnsupportedEncoding)?;
452    let (bytes, _, had_errors) = encoding.encode(content);
453    if had_errors {
454        return Err(FileTextEncodeError::UnmappableCharacters);
455    }
456    Ok(bytes.into_owned())
457}
458
459fn is_text_like(text: &str) -> bool {
460    text.chars()
461        .all(|ch| matches!(ch, '\n' | '\r' | '\t' | '\u{000c}') || !ch.is_control())
462}
463
464/// Truncates `content` to at most `max_bytes`, preferring a line boundary and falling back to a
465/// grapheme-cluster boundary so a multibyte character or emoji cluster is never split. Returns true
466/// when content was cut.
467pub(crate) fn truncate_inline_text(content: &mut String, max_bytes: usize) -> bool {
468    if content.len() <= max_bytes {
469        return false;
470    }
471
472    // Grapheme-cluster-safe byte cutoff within the budget (shared with truncate_utf8_to_max_bytes).
473    let end = crate::grapheme_safe_cutoff(content, max_bytes);
474    let cut = match content[..end].rfind('\n') {
475        // Keep whole lines when possible; a single oversized line is cut at `end`.
476        Some(idx) if idx > 0 => idx + 1,
477        _ => end,
478    };
479    content.truncate(cut);
480    true
481}
482
483/// Returns true when a file has multiple hard links.
484///
485/// Multiple links can allow path-based workspace guards to be bypassed by
486/// linking a workspace path to external sensitive content.
487pub(crate) fn has_multiple_hard_links(metadata: &Metadata) -> bool {
488    link_count(metadata) > 1
489}
490
491pub(crate) fn ensure_regular_file(
492    metadata: &Metadata,
493    path: &Path,
494    hard_link_error: &str,
495) -> Result<(), BoxError> {
496    if has_multiple_hard_links(metadata) {
497        return Err(format!("{} (path: {})", hard_link_error, path.display()).into());
498    }
499
500    if !metadata.is_file() {
501        return Err(format!(
502            "Path does not point to a regular file (path: {})",
503            path.display()
504        )
505        .into());
506    }
507
508    Ok(())
509}
510
511pub(crate) fn ensure_file_size_within_limit(
512    metadata: &Metadata,
513    path: &Path,
514    max_size_bytes: u64,
515) -> Result<(), BoxError> {
516    if metadata.len() > max_size_bytes {
517        return Err(format!(
518            "File size {} exceeds maximum allowed size of {} bytes (path: {})",
519            metadata.len(),
520            max_size_bytes,
521            path.display()
522        )
523        .into());
524    }
525
526    Ok(())
527}
528
529#[cfg(unix)]
530fn link_count(metadata: &Metadata) -> u64 {
531    use std::os::unix::fs::MetadataExt;
532    metadata.nlink()
533}
534
535#[cfg(windows)]
536fn link_count(_metadata: &Metadata) -> u64 {
537    // Rust stable does not currently expose a portable, stable Windows hard-link
538    // count API on `std::fs::Metadata`. Returning 1 avoids false positive blocks
539    // and keeps Windows builds stable until a supported API is available.
540    1
541}
542
543#[cfg(not(any(unix, windows)))]
544fn link_count(_metadata: &Metadata) -> u64 {
545    1
546}
547
548/// Atomically writes data to a file by first writing to a temporary file and then renaming it into place.
549pub async fn atomic_write_file(
550    target_path: &Path,
551    data: &[u8],
552    existing_permissions: Option<&Permissions>,
553) -> Result<(), BoxError> {
554    let temp_path =
555        write_temp_file_for_atomic_replace(target_path, data, existing_permissions).await?;
556
557    if let Err(err) = commit_atomic_replace(&temp_path, target_path).await {
558        let _ = tokio::fs::remove_file(&temp_path).await;
559        return Err(err);
560    }
561
562    Ok(())
563}
564
565pub(crate) async fn write_temp_file_for_atomic_replace(
566    target_path: &Path,
567    data: &[u8],
568    existing_permissions: Option<&Permissions>,
569) -> Result<PathBuf, BoxError> {
570    for _ in 0..16 {
571        let temp_path = atomic_temp_path(target_path)?;
572        let mut file = match tokio::fs::OpenOptions::new()
573            .create_new(true)
574            .write(true)
575            .open(&temp_path)
576            .await
577        {
578            Ok(file) => file,
579            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
580            Err(err) => {
581                return Err(format!(
582                    "Failed to create temporary file (target_path: {}, temp_path: {}): {err}",
583                    target_path.display(),
584                    temp_path.display()
585                )
586                .into());
587            }
588        };
589
590        let write_result = async {
591            file.write_all(data)
592                .await
593                .map_err(|err| {
594                    format!(
595                        "Failed to write temporary file (target_path: {}, temp_path: {}): {err}",
596                        target_path.display(),
597                        temp_path.display()
598                    )
599                })?;
600
601            if let Some(permissions) = existing_permissions {
602                tokio::fs::set_permissions(&temp_path, permissions.clone())
603                    .await
604                    .map_err(|err| {
605                        format!(
606                            "Failed to apply file permissions (target_path: {}, temp_path: {}): {err}",
607                            target_path.display(),
608                            temp_path.display()
609                        )
610                    })?;
611            }
612
613            file.sync_all()
614                .await
615                .map_err(|err| {
616                    format!(
617                        "Failed to sync temporary file (target_path: {}, temp_path: {}): {err}",
618                        target_path.display(),
619                        temp_path.display()
620                    )
621                })?;
622
623            Ok::<(), BoxError>(())
624        }
625        .await;
626        drop(file);
627
628        if let Err(err) = write_result {
629            let _ = tokio::fs::remove_file(&temp_path).await;
630            return Err(err);
631        }
632
633        return Ok(temp_path);
634    }
635
636    Err(format!(
637        "Failed to allocate unique temporary file for atomic write (target_path: {})",
638        target_path.display()
639    )
640    .into())
641}
642
643pub(crate) async fn commit_atomic_replace(
644    temp_path: &Path,
645    target_path: &Path,
646) -> Result<(), BoxError> {
647    tokio::fs::rename(temp_path, target_path)
648        .await
649        .map_err(|err| {
650            format!(
651                "Failed to atomically replace file (temp_path: {}, target_path: {}): {err}",
652                temp_path.display(),
653                target_path.display()
654            )
655            .into()
656        })
657}
658
659fn atomic_temp_path(target_path: &Path) -> Result<PathBuf, BoxError> {
660    let parent = target_path.parent().ok_or_else(|| {
661        format!(
662            "Failed to determine parent directory for write target (target_path: {})",
663            target_path.display()
664        )
665    })?;
666    let file_name = target_path.file_name().ok_or_else(|| {
667        format!(
668            "Failed to determine file name for write target (target_path: {})",
669            target_path.display()
670        )
671    })?;
672
673    let mut temp_name = OsString::from(".");
674    temp_name.push(file_name);
675    temp_name.push(format!(".anda-tmp-{:016x}", rand::random::<u64>()));
676
677    Ok(parent.join(temp_name))
678}
679
680/// Finds the nearest existing path component and returns the missing tail components.
681pub(crate) async fn nearest_existing_ancestor(
682    path: &Path,
683) -> Result<(PathBuf, Vec<OsString>), BoxError> {
684    let mut current = path.to_path_buf();
685    let mut missing_components = Vec::new();
686
687    loop {
688        match tokio::fs::symlink_metadata(&current).await {
689            Ok(_) => return Ok((current, missing_components)),
690            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
691                let file_name = current.file_name().ok_or_else(|| {
692                    format!(
693                        "Access to paths outside the workspace is not allowed while resolving ancestor (requested_path: {}, current_path: {})",
694                        path.display(),
695                        current.display()
696                    )
697                })?;
698                missing_components.push(file_name.to_os_string());
699                current = current
700                    .parent()
701                    .ok_or_else(|| {
702                        format!(
703                            "Access to paths outside the workspace is not allowed while resolving ancestor (requested_path: {}, current_path: {})",
704                            path.display(),
705                            current.display()
706                        )
707                    })?
708                    .to_path_buf();
709            }
710            Err(err) => {
711                return Err(format!(
712                    "Failed to inspect file path while resolving ancestor (requested_path: {}, current_path: {}): {err}",
713                    path.display(),
714                    current.display()
715                )
716                .into())
717            }
718        }
719    }
720}
721
722pub(crate) fn normalize_relative_path(path: &Path) -> String {
723    let value = path
724        .to_string_lossy()
725        .replace(std::path::MAIN_SEPARATOR, "/");
726    if value.is_empty() {
727        ".".to_string()
728    } else {
729        value
730    }
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736    use anda_core::RequestMeta;
737    use serde_json::json;
738
739    fn temp_dir(name: &str) -> PathBuf {
740        std::env::temp_dir().join(format!("anda-fs-{name}-{:016x}", rand::random::<u64>()))
741    }
742
743    #[test]
744    fn fs_tools_form_one_capability_group() {
745        use crate::context::BaseCtx;
746        use anda_core::ToolSet;
747        use std::sync::Arc;
748
749        let workspace = PathBuf::from("/tmp/anda-fs-group");
750        let mut tools = ToolSet::<BaseCtx>::new();
751        tools
752            .add(Arc::new(ReadFileTool::new(workspace.clone())))
753            .unwrap();
754        tools
755            .add(Arc::new(WriteFileTool::new(workspace.clone())))
756            .unwrap();
757        tools
758            .add(Arc::new(EditFileTool::new(workspace.clone())))
759            .unwrap();
760        tools.add(Arc::new(SearchFileTool::new(workspace))).unwrap();
761
762        let groups = tools.groups();
763        assert_eq!(groups.len(), 1);
764        assert_eq!(groups[0].id, FS_TOOL_GROUP_ID);
765        // All four registered tools land in the group, sorted by name.
766        assert_eq!(
767            groups[0].members,
768            vec![
769                "edit_file".to_string(),
770                "read_file".to_string(),
771                "search_file".to_string(),
772                "write_file".to_string(),
773            ]
774        );
775        assert!(groups[0].instructions.is_some());
776    }
777
778    #[test]
779    fn workspace_helpers_normalize_dedupe_and_report_empty_sets() {
780        let first = PathBuf::from("/tmp/one");
781        let second = PathBuf::from("/tmp/two");
782        let third = PathBuf::from("/tmp/three");
783        let fourth = PathBuf::from("/tmp/four");
784
785        assert_eq!(
786            normalize_workspaces(vec![
787                PathBuf::new(),
788                first.clone(),
789                first.clone(),
790                second.clone()
791            ]),
792            vec![first.clone(), second.clone()]
793        );
794        assert_eq!(format_workspaces(&[]), "<none>");
795        assert_eq!(
796            workspace_access_error("Path", "requested_path", "file.txt", &[], Vec::new())
797                .to_string(),
798            "Path is not accessible from any configured workspace (requested_path: file.txt, workspaces: [<none>])"
799        );
800
801        let mut meta = RequestMeta::default();
802        meta.extra.insert(
803            "workspace".to_string(),
804            json!([first, "", second.clone(), second]),
805        );
806        meta.extra
807            .insert("workspaces".to_string(), json!(third.clone()));
808
809        let workspaces = tool_workspaces(&meta, &[third, fourth.clone()]);
810        assert_eq!(
811            workspaces,
812            vec![
813                PathBuf::from("/tmp/one"),
814                PathBuf::from("/tmp/two"),
815                PathBuf::from("/tmp/three"),
816                fourth
817            ]
818        );
819    }
820
821    #[test]
822    fn file_text_encoding_decodes_legacy_text_and_rejects_binary() {
823        let gbk = vec![0xd6, 0xd0, 0xce, 0xc4, b'.', b't', b'x', b't', b'\n'];
824
825        let decoded = decode_file_text_with_fallback(gbk.clone(), Some(encoding_rs::GBK)).unwrap();
826        assert_eq!(
827            decoded,
828            DecodedFileText {
829                text: "中文.txt\n".to_string(),
830                encoding: "gbk".to_string(),
831            }
832        );
833
834        let utf8 = decode_file_text_with_fallback(
835            "中文.txt\n".as_bytes().to_vec(),
836            Some(encoding_rs::GBK),
837        )
838        .unwrap();
839        assert_eq!(utf8.text, "中文.txt\n");
840        assert_eq!(utf8.encoding, UTF8_ENCODING);
841
842        let binary = vec![0xff, 0x00, 0x81, 0x7f];
843        assert_eq!(
844            decode_file_text_with_fallback(binary.clone(), Some(encoding_rs::GBK)).unwrap_err(),
845            binary
846        );
847    }
848
849    #[test]
850    fn file_text_encoding_encodes_legacy_text() {
851        let gbk = vec![0xd6, 0xd0, 0xce, 0xc4, b'.', b't', b'x', b't', b'\n'];
852
853        assert_eq!(encode_file_text("中文.txt\n", "gbk").unwrap(), gbk);
854        assert_eq!(
855            encode_file_text("hello", "utf-8").unwrap(),
856            b"hello".to_vec()
857        );
858        assert_eq!(
859            encode_file_text("hello", "not-an-encoding").unwrap_err(),
860            FileTextEncodeError::UnsupportedEncoding
861        );
862    }
863
864    #[test]
865    fn truncate_inline_text_prefers_line_then_char_boundaries() {
866        let mut text = "short".to_string();
867        assert!(!truncate_inline_text(&mut text, 10));
868        assert_eq!(text, "short");
869
870        let mut text = "line one\nline two\nline three".to_string();
871        assert!(truncate_inline_text(&mut text, 20));
872        assert_eq!(text, "line one\nline two\n");
873
874        // A single oversized line is cut at a grapheme boundary instead of dropped.
875        let mut text = "中文内容没有换行".to_string();
876        assert!(truncate_inline_text(&mut text, 10));
877        assert_eq!(text, "中文内");
878
879        // A leading newline does not produce an empty result.
880        let mut text = "\nabcdefghijklmnop".to_string();
881        assert!(truncate_inline_text(&mut text, 8));
882        assert_eq!(text, "\nabcdefg");
883
884        // A multi-codepoint grapheme cluster (family emoji joined by ZWJ, 25 bytes) on a single
885        // oversized line is never split: a budget landing mid-cluster backs off to the previous
886        // cluster boundary.
887        let family = "👨‍👩‍👧‍👦";
888        let mut text = family.repeat(3); // 75 bytes, no newline
889        assert!(truncate_inline_text(&mut text, 60));
890        assert_eq!(text, family.repeat(2));
891    }
892
893    #[test]
894    fn file_metadata_guards_reject_non_regular_large_and_hardlinked_files() {
895        let root = temp_dir("metadata");
896        std::fs::create_dir_all(&root).unwrap();
897        let file = root.join("file.txt");
898        std::fs::write(&file, b"abcd").unwrap();
899
900        let file_meta = std::fs::metadata(&file).unwrap();
901        ensure_file_size_within_limit(&file_meta, &file, 4).unwrap();
902        assert!(
903            ensure_file_size_within_limit(&file_meta, &file, 3)
904                .unwrap_err()
905                .to_string()
906                .contains("exceeds maximum")
907        );
908
909        let dir_meta = std::fs::symlink_metadata(&root).unwrap();
910        assert!(
911            ensure_regular_file(&dir_meta, &root, "hard links blocked")
912                .unwrap_err()
913                .to_string()
914                .contains("Path does not point to a regular file")
915                || ensure_regular_file(&dir_meta, &root, "hard links blocked")
916                    .unwrap_err()
917                    .to_string()
918                    .contains("hard links blocked")
919        );
920
921        #[cfg(unix)]
922        {
923            let link = root.join("link.txt");
924            std::fs::hard_link(&file, &link).unwrap();
925            let linked_meta = std::fs::metadata(&file).unwrap();
926            assert!(has_multiple_hard_links(&linked_meta));
927            assert!(
928                ensure_regular_file(&linked_meta, &file, "hard links blocked")
929                    .unwrap_err()
930                    .to_string()
931                    .contains("hard links blocked")
932            );
933        }
934
935        let _ = std::fs::remove_dir_all(root);
936    }
937
938    #[tokio::test(flavor = "current_thread")]
939    async fn resolve_helpers_cover_parent_paths_missing_tails_and_errors() {
940        let root = temp_dir("resolve");
941        tokio::fs::create_dir_all(root.join("dir")).await.unwrap();
942        tokio::fs::write(root.join("dir/file.txt"), b"ok")
943            .await
944            .unwrap();
945
946        let parent_read = resolve_read_path(&root, "dir/../dir/file.txt")
947            .await
948            .unwrap();
949        assert_eq!(
950            parent_read,
951            tokio::fs::canonicalize(root.join("dir/file.txt"))
952                .await
953                .unwrap()
954        );
955
956        let canonical_root = tokio::fs::canonicalize(&root).await.unwrap();
957        let write_path = resolve_write_path(&root, "new/nested/file.txt")
958            .await
959            .unwrap();
960        assert_eq!(write_path, canonical_root.join("new/nested/file.txt"));
961
962        let selected = resolve_write_path_in_workspaces(
963            &[root.join("missing"), root.clone()],
964            "new/nested/file.txt",
965        )
966        .await
967        .unwrap();
968        assert_eq!(selected.workspace, root);
969        assert!(selected.path.ends_with("new/nested/file.txt"));
970
971        let read_err = resolve_read_path_in_workspaces(&[], "missing.txt")
972            .await
973            .unwrap_err();
974        assert!(read_err.to_string().contains("workspaces: [<none>]"));
975
976        let missing_workspace = resolve_workspace_path(Path::new("/definitely/missing/anda"))
977            .await
978            .unwrap_err();
979        assert!(
980            missing_workspace
981                .to_string()
982                .contains("Failed to resolve workspace path")
983        );
984
985        assert!(path_contains_parent_reference(Path::new("a/../b")));
986        assert!(!path_contains_parent_reference(Path::new("a/b")));
987        assert!(
988            ensure_path_in_workspace_namespace(
989                Path::new("/tmp/work"),
990                Path::new("/tmp/work"),
991                Path::new("/tmp/other/file.txt"),
992            )
993            .unwrap_err()
994            .to_string()
995            .contains("outside the workspace")
996        );
997
998        let _ = tokio::fs::remove_dir_all(selected.workspace).await;
999    }
1000
1001    #[tokio::test(flavor = "current_thread")]
1002    async fn atomic_write_helpers_commit_cleanup_and_path_formatting() {
1003        let root = temp_dir("atomic");
1004        tokio::fs::create_dir_all(&root).await.unwrap();
1005        let target = root.join("file.txt");
1006
1007        atomic_write_file(&target, b"first", None).await.unwrap();
1008        assert_eq!(tokio::fs::read(&target).await.unwrap(), b"first");
1009
1010        let permissions = tokio::fs::metadata(&target).await.unwrap().permissions();
1011        atomic_write_file(&target, b"second", Some(&permissions))
1012            .await
1013            .unwrap();
1014        assert_eq!(tokio::fs::read(&target).await.unwrap(), b"second");
1015
1016        let temp = write_temp_file_for_atomic_replace(&target, b"third", None)
1017            .await
1018            .unwrap();
1019        assert!(
1020            temp.file_name()
1021                .unwrap()
1022                .to_string_lossy()
1023                .contains(".anda-tmp-")
1024        );
1025        commit_atomic_replace(&temp, &target).await.unwrap();
1026        assert_eq!(tokio::fs::read(&target).await.unwrap(), b"third");
1027
1028        let missing_temp = root.join("missing-temp");
1029        assert!(
1030            commit_atomic_replace(&missing_temp, &target)
1031                .await
1032                .unwrap_err()
1033                .to_string()
1034                .contains("Failed to atomically replace file")
1035        );
1036        assert!(
1037            atomic_write_file(&root, b"cannot replace a directory", None)
1038                .await
1039                .unwrap_err()
1040                .to_string()
1041                .contains("Failed to atomically replace file")
1042        );
1043        assert!(
1044            write_temp_file_for_atomic_replace(&root.join("missing/file.txt"), b"bad", None)
1045                .await
1046                .unwrap_err()
1047                .to_string()
1048                .contains("Failed to create temporary file")
1049        );
1050        assert!(
1051            write_temp_file_for_atomic_replace(Path::new(""), b"bad", None)
1052                .await
1053                .unwrap_err()
1054                .to_string()
1055                .contains("Failed to determine")
1056        );
1057
1058        let (ancestor, missing) = nearest_existing_ancestor(&root.join("a/b/c.txt"))
1059            .await
1060            .unwrap();
1061        assert_eq!(ancestor, root);
1062        assert_eq!(missing.len(), 3);
1063        assert!(
1064            nearest_existing_ancestor(Path::new(""))
1065                .await
1066                .unwrap_err()
1067                .to_string()
1068                .contains("outside the workspace")
1069        );
1070        assert_eq!(normalize_relative_path(Path::new("")), ".");
1071        assert_eq!(normalize_relative_path(Path::new("a/b")), "a/b");
1072        assert_eq!(default_write_encoding(), UTF8_ENCODING);
1073
1074        let _ = tokio::fs::remove_dir_all(root).await;
1075    }
1076
1077    #[tokio::test(flavor = "current_thread")]
1078    async fn deterministic_error_branches_for_read_and_metadata_guards() {
1079        let root = temp_dir("fs-errors");
1080        tokio::fs::create_dir_all(&root).await.unwrap();
1081
1082        let err = resolve_read_path(&root, "missing/../missing.txt")
1083            .await
1084            .unwrap_err();
1085        assert!(err.to_string().contains("Failed to resolve file path"));
1086
1087        #[cfg(unix)]
1088        {
1089            use std::os::unix::fs::symlink;
1090
1091            let link = root.join("link");
1092            symlink(root.join("missing-target"), &link).unwrap();
1093            let meta = std::fs::symlink_metadata(&link).unwrap();
1094            assert!(
1095                ensure_regular_file(&meta, &link, "hard links blocked")
1096                    .unwrap_err()
1097                    .to_string()
1098                    .contains("Path does not point to a regular file")
1099            );
1100        }
1101
1102        let _ = tokio::fs::remove_dir_all(root).await;
1103    }
1104}