Skip to main content

lean_agent_core/
workspace.rs

1//! Isolated, disposable copies of a Lake project for replay.
2//!
3//! A [`Workspace`] is a temp-directory copy of the project tree with build
4//! output and version-control metadata skipped, so an attempt can be patched and
5//! compiled without touching the source of truth. Dropping the workspace removes
6//! the copy unless it was materialized with `keep` set.
7
8use crate::{Error, Result};
9use camino::{Utf8Path, Utf8PathBuf};
10use std::fs;
11use tempfile::TempDir;
12use tracing::warn;
13use walkdir::WalkDir;
14
15/// Directory names never copied into a workspace.
16///
17/// Build output is rebuilt on demand by `lake lean`, and git metadata is never
18/// needed, so both are skipped to keep copies small and free of baked-in paths.
19const DEFAULT_SKIP: &[&str] = &[".git", ".lake"];
20
21/// What to copy when materializing a workspace.
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct CopyOptions {
24    /// Directory component names skipped while copying.
25    pub skip_dirs: Vec<String>,
26}
27
28impl Default for CopyOptions {
29    fn default() -> Self {
30        Self {
31            skip_dirs: DEFAULT_SKIP.iter().map(|name| (*name).to_owned()).collect(),
32        }
33    }
34}
35
36impl CopyOptions {
37    /// True when `name` is a directory the copy should skip.
38    fn skips(&self, name: &str) -> bool {
39        self.skip_dirs.iter().any(|skip| skip == name)
40    }
41}
42
43/// An isolated copy of a Lake project.
44///
45/// When `keep` is false the backing temp directory is deleted on drop. When
46/// `keep` is true the directory is persisted and its path is returned by
47/// [`Workspace::root`] for inspection.
48pub struct Workspace {
49    root: Utf8PathBuf,
50    handle: Option<TempDir>,
51}
52
53impl Workspace {
54    /// Copy `lake_root` into a fresh temp directory and return the workspace.
55    ///
56    /// The copy follows `options.skip_dirs`. With `keep` set, the temp directory
57    /// is persisted past drop so its contents can be inspected after a run.
58    pub fn materialize(lake_root: &Utf8Path, keep: bool, options: &CopyOptions) -> Result<Self> {
59        let temp = TempDir::new().map_err(|source| Error::WorkspaceCopy {
60            path: lake_root.to_path_buf(),
61            source,
62        })?;
63        let dest = Utf8PathBuf::from_path_buf(temp.path().to_path_buf())
64            .map_err(|path| Error::NonUtf8Path { path })?;
65
66        copy_tree(lake_root, &dest, options)?;
67
68        if keep {
69            let kept = temp.keep();
70            let root =
71                Utf8PathBuf::from_path_buf(kept).map_err(|path| Error::NonUtf8Path { path })?;
72            Ok(Self { root, handle: None })
73        } else {
74            Ok(Self {
75                root: dest,
76                handle: Some(temp),
77            })
78        }
79    }
80
81    /// Path to the workspace copy's root.
82    #[must_use]
83    pub fn root(&self) -> &Utf8Path {
84        &self.root
85    }
86
87    /// True when this workspace will outlive its drop.
88    #[must_use]
89    pub const fn is_kept(&self) -> bool {
90        self.handle.is_none()
91    }
92}
93
94/// Recursively copy `src` into `dest`, skipping configured directories and
95/// symlinks. Regular files are copied; the destination tree mirrors `src`.
96fn copy_tree(src: &Utf8Path, dest: &Utf8Path, options: &CopyOptions) -> Result<()> {
97    let copy_err = |path: &Utf8Path, source: std::io::Error| Error::WorkspaceCopy {
98        path: path.to_path_buf(),
99        source,
100    };
101
102    let mut walker = WalkDir::new(src).follow_links(false).into_iter();
103    while let Some(entry) = walker.next() {
104        let entry = entry.map_err(|err| Error::WorkspaceCopy {
105            path: src.to_path_buf(),
106            source: err.into(),
107        })?;
108        let path = entry.path();
109        let Some(utf8_path) = Utf8Path::from_path(path) else {
110            warn!(path = %path.display(), "skipping non-UTF-8 path while copying workspace");
111            continue;
112        };
113
114        let relative = match utf8_path.strip_prefix(src) {
115            Ok(relative) => relative,
116            Err(_) => continue,
117        };
118
119        let file_type = entry.file_type();
120        if file_type.is_dir() {
121            if let Some(name) = utf8_path.file_name() {
122                if options.skips(name) {
123                    walker.skip_current_dir();
124                    continue;
125                }
126            }
127            if relative.as_str().is_empty() {
128                fs::create_dir_all(dest).map_err(|source| copy_err(dest, source))?;
129            } else {
130                let target = dest.join(relative);
131                fs::create_dir_all(&target).map_err(|source| copy_err(&target, source))?;
132            }
133        } else if file_type.is_file() {
134            let target = dest.join(relative);
135            if let Some(parent) = target.parent() {
136                fs::create_dir_all(parent).map_err(|source| copy_err(parent, source))?;
137            }
138            fs::copy(utf8_path, &target).map_err(|source| copy_err(&target, source))?;
139        } else {
140            warn!(path = %utf8_path, "skipping non-regular file while copying workspace");
141        }
142    }
143    Ok(())
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn seed(root: &Utf8Path, rel: &str, contents: &str) -> Result<()> {
151        let path = root.join(rel);
152        if let Some(parent) = path.parent() {
153            fs::create_dir_all(parent)?;
154        }
155        fs::write(path, contents)?;
156        Ok(())
157    }
158
159    #[test]
160    fn copies_sources_and_skips_build_and_git() -> Result<()> {
161        let source = TempDir::new()?;
162        let src = Utf8PathBuf::from_path_buf(source.path().to_path_buf())
163            .map_err(|path| Error::NonUtf8Path { path })?;
164        seed(&src, "Demo.lean", "theorem t : True := trivial\n")?;
165        seed(&src, "sub/Inner.lean", "def x := 1\n")?;
166        seed(&src, ".lake/build/lib/stale.olean", "binary")?;
167        seed(&src, ".git/config", "[core]\n")?;
168
169        let ws = Workspace::materialize(&src, false, &CopyOptions::default())?;
170        let root = ws.root().to_path_buf();
171
172        assert!(root.join("Demo.lean").exists());
173        assert!(root.join("sub/Inner.lean").exists());
174        assert!(!root.join(".lake").exists());
175        assert!(!root.join(".git").exists());
176        assert_eq!(
177            fs::read_to_string(root.join("sub/Inner.lean"))?,
178            "def x := 1\n"
179        );
180        Ok(())
181    }
182
183    #[test]
184    fn drop_removes_workspace_unless_kept() -> Result<()> {
185        let source = TempDir::new()?;
186        let src = Utf8PathBuf::from_path_buf(source.path().to_path_buf())
187            .map_err(|path| Error::NonUtf8Path { path })?;
188        seed(&src, "Demo.lean", "x\n")?;
189
190        let disposable_root = {
191            let ws = Workspace::materialize(&src, false, &CopyOptions::default())?;
192            let root = ws.root().to_path_buf();
193            assert!(root.exists());
194            assert!(!ws.is_kept());
195            root
196        };
197        assert!(!disposable_root.exists());
198
199        let ws = Workspace::materialize(&src, true, &CopyOptions::default())?;
200        let kept_root = ws.root().to_path_buf();
201        assert!(ws.is_kept());
202        drop(ws);
203        assert!(kept_root.exists());
204        fs::remove_dir_all(&kept_root)?;
205        Ok(())
206    }
207}