lean_agent_core/
workspace.rs1use crate::{Error, Result};
9use camino::{Utf8Path, Utf8PathBuf};
10use std::fs;
11use tempfile::TempDir;
12use tracing::warn;
13use walkdir::WalkDir;
14
15const DEFAULT_SKIP: &[&str] = &[".git", ".lake"];
20
21#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct CopyOptions {
24 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 fn skips(&self, name: &str) -> bool {
39 self.skip_dirs.iter().any(|skip| skip == name)
40 }
41}
42
43pub struct Workspace {
49 root: Utf8PathBuf,
50 handle: Option<TempDir>,
51}
52
53impl Workspace {
54 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 #[must_use]
83 pub fn root(&self) -> &Utf8Path {
84 &self.root
85 }
86
87 #[must_use]
89 pub const fn is_kept(&self) -> bool {
90 self.handle.is_none()
91 }
92}
93
94fn 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}