Skip to main content

oxicode_agent/tools/
hashline_fs.rs

1//! Concrete `HashlineFs` backed by `tokio::fs`, `PathGuard`, and the global
2//! `file_mutation_queue`.
3//!
4//! This is the bridge between the pure-function `oxicode-hashline` crate and the
5//! oxicode-agent runtime. The `Patcher` calls these methods; security and
6//! serialization are handled transparently.
7
8use async_trait::async_trait;
9use oxicode_hashline::mismatch::HashlineError;
10use oxicode_hashline::patcher::HashlineFs;
11use std::path::{Path, PathBuf};
12use tokio::fs;
13
14use super::file_mutation_queue::global_mutation_queue;
15use super::path_security::PathGuard;
16
17/// `tokio::fs`-backed `HashlineFs` with workspace-bound security and
18/// per-file write serialization.
19pub struct TokioHashlineFs {
20    root: PathBuf,
21}
22
23impl TokioHashlineFs {
24    /// Create with the given root directory (workspace root).
25    pub fn new(root: PathBuf) -> Self {
26        Self { root }
27    }
28
29    fn validate(&self, path: &str) -> Result<PathBuf, HashlineError> {
30        let guard = PathGuard::new(&self.root);
31        guard
32            .validate_traversal(Path::new(path))
33            .map_err(|e| HashlineError::Io(std::io::Error::other(e.to_string())))
34    }
35}
36
37#[async_trait]
38impl HashlineFs for TokioHashlineFs {
39    async fn read_text(&self, path: &str) -> Result<String, HashlineError> {
40        let validated = self.validate(path)?;
41        match fs::read_to_string(&validated).await {
42            Ok(text) => Ok(text),
43            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(HashlineError::NotFound {
44                path: path.to_string(),
45            }),
46            Err(e) => Err(HashlineError::Io(e)),
47        }
48    }
49
50    async fn write_text(&self, path: &str, text: &str) -> Result<String, HashlineError> {
51        let validated = self.validate(path)?;
52        let text_owned = text.to_string();
53        let result_path = validated.clone();
54        global_mutation_queue()
55            .with_queue(&validated, || async {
56                fs::write(&validated, &text_owned).await
57            })
58            .await
59            .map(|_| path.to_string())
60            .map_err(|e: std::io::Error| {
61                let _ = &result_path; // suppress unused warning
62                HashlineError::Io(e)
63            })
64    }
65
66    async fn preflight_write(&self, path: &str) -> Result<(), HashlineError> {
67        let validated = self.validate(path)?;
68        // Check that the parent directory exists.
69        if let Some(parent) = validated.parent()
70            && !parent.as_os_str().is_empty()
71            && fs::metadata(parent).await.is_err()
72        {
73            return Err(HashlineError::Io(std::io::Error::new(
74                std::io::ErrorKind::NotFound,
75                format!("Parent directory does not exist: {}", parent.display()),
76            )));
77        }
78        Ok(())
79    }
80
81    fn canonical_path(&self, path: &str) -> String {
82        let guard = PathGuard::new(&self.root);
83        match guard.validate_traversal(Path::new(path)) {
84            Ok(p) => {
85                // Strip the root prefix to get a canonical relative path.
86                match p.strip_prefix(&self.root) {
87                    Ok(rel) => rel.to_string_lossy().into_owned(),
88                    Err(_) => p.to_string_lossy().into_owned(),
89                }
90            }
91            Err(_) => path.to_string(),
92        }
93    }
94}