1use crate::lesson::SetupFile;
16use anyhow::{Context, Result};
17use std::fs;
18use std::path::{Component, Path, PathBuf};
19
20pub struct Playground {
22 root: PathBuf,
24 lesson_id: Option<String>,
26 setup: Vec<SetupFile>,
28 cwd: PathBuf,
30 display: String,
32}
33
34impl Playground {
35 pub fn open(root: PathBuf) -> Result<Self> {
37 Self::open_with_display(root, crate::guard::PLAYGROUND_DISPLAY)
38 }
39
40 pub fn open_with_display(root: PathBuf, display: impl Into<String>) -> Result<Self> {
43 fs::create_dir_all(&root)
44 .with_context(|| format!("Failed to create playground at {}", root.display()))?;
45 let root = root
46 .canonicalize()
47 .context("Failed to canonicalize playground root")?;
48 Ok(Self {
49 cwd: root.clone(),
50 root,
51 lesson_id: None,
52 setup: Vec::new(),
53 display: display.into(),
54 })
55 }
56
57 pub fn root(&self) -> &Path {
59 &self.root
60 }
61
62 pub fn cwd(&self) -> &Path {
64 &self.cwd
65 }
66
67 pub fn has_lesson(&self) -> bool {
69 self.lesson_id.is_some()
70 }
71
72 pub fn display_cwd(&self) -> String {
75 match self.cwd.strip_prefix(&self.root) {
76 Ok(rel) if rel.as_os_str().is_empty() => self.display.clone(),
77 Ok(rel) => format!("{}/{}", self.display, rel.display()),
78 Err(_) => self.cwd.display().to_string(),
79 }
80 }
81
82 pub fn enter_lesson(&mut self, lesson_id: &str, setup: &[SetupFile]) -> Result<PathBuf> {
86 anyhow::ensure!(
87 !lesson_id.is_empty()
88 && lesson_id
89 .chars()
90 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
91 "Invalid lesson id for playground directory: {lesson_id}"
92 );
93
94 let dir = self.root.join(lesson_id);
95 fs::create_dir_all(&dir)
96 .with_context(|| format!("Failed to create lesson playground: {}", dir.display()))?;
97 Self::materialize(&dir, setup, false)?;
98
99 self.lesson_id = Some(lesson_id.to_string());
100 self.setup = setup.to_vec();
101 self.cwd = dir
102 .canonicalize()
103 .context("Failed to canonicalize lesson playground directory")?;
104 Ok(self.cwd.clone())
105 }
106
107 pub fn reset_lesson(&mut self) -> Result<PathBuf> {
112 let lesson_id = self
113 .lesson_id
114 .clone()
115 .ok_or_else(|| anyhow::anyhow!("No active lesson to reset"))?;
116
117 let dir = self.root.join(&lesson_id);
118 if dir.exists() {
119 let canonical = dir
120 .canonicalize()
121 .context("Failed to canonicalize lesson directory before reset")?;
122 anyhow::ensure!(
125 canonical != self.root && canonical.starts_with(&self.root),
126 "Refusing to reset: {} is not inside the playground",
127 canonical.display()
128 );
129 fs::remove_dir_all(&canonical).with_context(|| {
130 format!("Failed to remove lesson playground: {}", canonical.display())
131 })?;
132 }
133
134 fs::create_dir_all(&dir)?;
135 let setup = self.setup.clone();
136 Self::materialize(&dir, &setup, true)?;
137 self.cwd = dir
138 .canonicalize()
139 .context("Failed to canonicalize lesson playground directory")?;
140 Ok(self.cwd.clone())
141 }
142
143 pub fn change_directory(&mut self, target: &str) -> Result<String> {
147 let target = target.trim();
148 let candidate = if target.is_empty() || target == "~" {
149 match &self.lesson_id {
151 Some(id) => self.root.join(id),
152 None => self.root.clone(),
153 }
154 } else if target.starts_with('/') {
155 PathBuf::from(target)
156 } else {
157 self.cwd.join(target)
158 };
159
160 let canonical = candidate
161 .canonicalize()
162 .map_err(|_| anyhow::anyhow!("No such directory: {target}"))?;
163
164 anyhow::ensure!(
165 canonical.starts_with(&self.root),
166 "'{target}' leaves {} — commands here stay inside it",
167 self.display
168 );
169 anyhow::ensure!(canonical.is_dir(), "Not a directory: {target}");
170
171 self.cwd = canonical;
172 Ok(self.display_cwd())
173 }
174
175 fn materialize(dir: &Path, files: &[SetupFile], overwrite: bool) -> Result<()> {
179 for file in files {
180 let rel = Path::new(&file.path);
181 let safe = rel.components().all(|c| matches!(c, Component::Normal(_)))
182 && !rel.as_os_str().is_empty();
183 anyhow::ensure!(safe, "Unsafe setup file path in lesson: {}", file.path);
184
185 let dest = dir.join(rel);
186 if let Some(parent) = dest.parent() {
187 fs::create_dir_all(parent)?;
188 }
189 if overwrite || !dest.exists() {
190 fs::write(&dest, &file.contents)
191 .with_context(|| format!("Failed to write setup file: {}", dest.display()))?;
192 }
193 }
194 Ok(())
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 fn temp_root(name: &str) -> PathBuf {
203 let dir = std::env::temp_dir().join(format!(
204 "arct-playground-test-{}-{}",
205 name,
206 std::process::id()
207 ));
208 let _ = fs::remove_dir_all(&dir);
209 dir
210 }
211
212 fn sample_setup() -> Vec<SetupFile> {
213 vec![
214 SetupFile {
215 path: "notes.txt".to_string(),
216 contents: "starter\n".to_string(),
217 },
218 SetupFile {
219 path: "logs/server.log".to_string(),
220 contents: "line1\nline2\n".to_string(),
221 },
222 ]
223 }
224
225 #[test]
226 fn test_open_creates_root_and_enter_materializes_setup() {
227 let root = temp_root("enter");
228 let mut pg = Playground::open(root.clone()).unwrap();
229 assert!(pg.root().exists());
230 assert_eq!(pg.cwd(), pg.root());
231
232 let dir = pg.enter_lesson("file-mgmt", &sample_setup()).unwrap();
233 assert!(dir.ends_with("file-mgmt"));
234 assert_eq!(pg.cwd(), dir.as_path());
235 assert_eq!(fs::read_to_string(dir.join("notes.txt")).unwrap(), "starter\n");
236 assert_eq!(
237 fs::read_to_string(dir.join("logs/server.log")).unwrap(),
238 "line1\nline2\n"
239 );
240 assert_eq!(pg.display_cwd(), "~/ArcAcademy/playground/file-mgmt");
241
242 let _ = fs::remove_dir_all(&root);
243 }
244
245 #[test]
246 fn test_reenter_preserves_learner_changes_but_reset_wipes() {
247 let root = temp_root("reset");
248 let mut pg = Playground::open(root.clone()).unwrap();
249 let dir = pg.enter_lesson("file-viewing", &sample_setup()).unwrap();
250
251 fs::write(dir.join("notes.txt"), "my edits\n").unwrap();
253 fs::write(dir.join("extra.txt"), "keep?\n").unwrap();
254
255 pg.enter_lesson("file-viewing", &sample_setup()).unwrap();
257 assert_eq!(fs::read_to_string(dir.join("notes.txt")).unwrap(), "my edits\n");
258 assert!(dir.join("extra.txt").exists());
259
260 pg.reset_lesson().unwrap();
262 assert_eq!(fs::read_to_string(dir.join("notes.txt")).unwrap(), "starter\n");
263 assert!(!dir.join("extra.txt").exists());
264 assert!(dir.join("logs/server.log").exists());
265
266 let _ = fs::remove_dir_all(&root);
267 }
268
269 #[test]
270 fn test_reset_without_lesson_errors() {
271 let root = temp_root("reset-none");
272 let mut pg = Playground::open(root.clone()).unwrap();
273 assert!(pg.reset_lesson().is_err());
274 let _ = fs::remove_dir_all(&root);
275 }
276
277 #[test]
278 fn test_change_directory_stays_contained() {
279 let root = temp_root("cd");
280 let mut pg = Playground::open(root.clone()).unwrap();
281 pg.enter_lesson("nav", &sample_setup()).unwrap();
282
283 pg.change_directory("logs").unwrap();
285 assert!(pg.display_cwd().ends_with("nav/logs"));
286 pg.change_directory("..").unwrap();
287 assert!(pg.display_cwd().ends_with("nav"));
288
289 pg.change_directory("..").unwrap();
291 assert_eq!(pg.cwd(), pg.root());
292
293 assert!(pg.change_directory("..").is_err());
295 assert_eq!(pg.cwd(), pg.root());
296 assert!(pg.change_directory("/etc").is_err());
297 assert!(pg.change_directory("../..").is_err());
298
299 assert!(pg.change_directory("nope").is_err());
301
302 pg.change_directory("").unwrap();
304 assert!(pg.display_cwd().ends_with("nav"));
305
306 let _ = fs::remove_dir_all(&root);
307 }
308
309 #[test]
310 fn test_unsafe_setup_paths_rejected() {
311 let root = temp_root("unsafe");
312 let mut pg = Playground::open(root.clone()).unwrap();
313
314 let evil = vec![SetupFile {
315 path: "../escape.txt".to_string(),
316 contents: "nope".to_string(),
317 }];
318 assert!(pg.enter_lesson("evil", &evil).is_err());
319 assert!(!root.join("escape.txt").exists());
320
321 let abs = vec![SetupFile {
322 path: "/tmp/abs-escape.txt".to_string(),
323 contents: "nope".to_string(),
324 }];
325 assert!(pg.enter_lesson("evil2", &abs).is_err());
326
327 let _ = fs::remove_dir_all(&root);
328 }
329
330 #[test]
331 fn test_invalid_lesson_id_rejected() {
332 let root = temp_root("badid");
333 let mut pg = Playground::open(root.clone()).unwrap();
334 assert!(pg.enter_lesson("../outside", &[]).is_err());
335 assert!(pg.enter_lesson("", &[]).is_err());
336 assert!(pg.enter_lesson("a/b", &[]).is_err());
337 let _ = fs::remove_dir_all(&root);
338 }
339}
340
341impl crate::lesson::PracticeWorld for Playground {
342 fn locate(&self, lesson_path: &str) -> Option<PathBuf> {
343 let base = match &self.lesson_id {
344 Some(id) => self.root.join(id),
345 None => self.root.clone(),
346 };
347 let rel = Path::new(lesson_path.trim_start_matches('/'));
348 if rel.components().any(|c| !matches!(c, Component::Normal(_))) {
349 return None;
350 }
351 Some(base.join(rel))
352 }
353
354 fn cwd_relative(&self) -> String {
355 let base = match &self.lesson_id {
356 Some(id) => self.root.join(id),
357 None => self.root.clone(),
358 };
359 self.cwd
360 .strip_prefix(&base)
361 .map(|rel| rel.to_string_lossy().to_string())
362 .unwrap_or_default()
363 }
364}