use crate::lesson::SetupFile;
use anyhow::{Context, Result};
use std::fs;
use std::path::{Component, Path, PathBuf};
pub struct Playground {
root: PathBuf,
lesson_id: Option<String>,
setup: Vec<SetupFile>,
cwd: PathBuf,
display: String,
}
impl Playground {
pub fn open(root: PathBuf) -> Result<Self> {
Self::open_with_display(root, crate::guard::PLAYGROUND_DISPLAY)
}
pub fn open_with_display(root: PathBuf, display: impl Into<String>) -> Result<Self> {
fs::create_dir_all(&root)
.with_context(|| format!("Failed to create playground at {}", root.display()))?;
let root = root
.canonicalize()
.context("Failed to canonicalize playground root")?;
Ok(Self {
cwd: root.clone(),
root,
lesson_id: None,
setup: Vec::new(),
display: display.into(),
})
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn cwd(&self) -> &Path {
&self.cwd
}
pub fn has_lesson(&self) -> bool {
self.lesson_id.is_some()
}
pub fn display_cwd(&self) -> String {
match self.cwd.strip_prefix(&self.root) {
Ok(rel) if rel.as_os_str().is_empty() => self.display.clone(),
Ok(rel) => format!("{}/{}", self.display, rel.display()),
Err(_) => self.cwd.display().to_string(),
}
}
pub fn enter_lesson(&mut self, lesson_id: &str, setup: &[SetupFile]) -> Result<PathBuf> {
anyhow::ensure!(
!lesson_id.is_empty()
&& lesson_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"Invalid lesson id for playground directory: {lesson_id}"
);
let dir = self.root.join(lesson_id);
fs::create_dir_all(&dir)
.with_context(|| format!("Failed to create lesson playground: {}", dir.display()))?;
Self::materialize(&dir, setup, false)?;
self.lesson_id = Some(lesson_id.to_string());
self.setup = setup.to_vec();
self.cwd = dir
.canonicalize()
.context("Failed to canonicalize lesson playground directory")?;
Ok(self.cwd.clone())
}
pub fn reset_lesson(&mut self) -> Result<PathBuf> {
let lesson_id = self
.lesson_id
.clone()
.ok_or_else(|| anyhow::anyhow!("No active lesson to reset"))?;
let dir = self.root.join(&lesson_id);
if dir.exists() {
let canonical = dir
.canonicalize()
.context("Failed to canonicalize lesson directory before reset")?;
anyhow::ensure!(
canonical != self.root && canonical.starts_with(&self.root),
"Refusing to reset: {} is not inside the playground",
canonical.display()
);
fs::remove_dir_all(&canonical).with_context(|| {
format!("Failed to remove lesson playground: {}", canonical.display())
})?;
}
fs::create_dir_all(&dir)?;
let setup = self.setup.clone();
Self::materialize(&dir, &setup, true)?;
self.cwd = dir
.canonicalize()
.context("Failed to canonicalize lesson playground directory")?;
Ok(self.cwd.clone())
}
pub fn change_directory(&mut self, target: &str) -> Result<String> {
let target = target.trim();
let candidate = if target.is_empty() || target == "~" {
match &self.lesson_id {
Some(id) => self.root.join(id),
None => self.root.clone(),
}
} else if target.starts_with('/') {
PathBuf::from(target)
} else {
self.cwd.join(target)
};
let canonical = candidate
.canonicalize()
.map_err(|_| anyhow::anyhow!("No such directory: {target}"))?;
anyhow::ensure!(
canonical.starts_with(&self.root),
"'{target}' leaves {} — commands here stay inside it",
self.display
);
anyhow::ensure!(canonical.is_dir(), "Not a directory: {target}");
self.cwd = canonical;
Ok(self.display_cwd())
}
fn materialize(dir: &Path, files: &[SetupFile], overwrite: bool) -> Result<()> {
for file in files {
let rel = Path::new(&file.path);
let safe = rel.components().all(|c| matches!(c, Component::Normal(_)))
&& !rel.as_os_str().is_empty();
anyhow::ensure!(safe, "Unsafe setup file path in lesson: {}", file.path);
let dest = dir.join(rel);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
if overwrite || !dest.exists() {
fs::write(&dest, &file.contents)
.with_context(|| format!("Failed to write setup file: {}", dest.display()))?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_root(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"arct-playground-test-{}-{}",
name,
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
dir
}
fn sample_setup() -> Vec<SetupFile> {
vec![
SetupFile {
path: "notes.txt".to_string(),
contents: "starter\n".to_string(),
},
SetupFile {
path: "logs/server.log".to_string(),
contents: "line1\nline2\n".to_string(),
},
]
}
#[test]
fn test_open_creates_root_and_enter_materializes_setup() {
let root = temp_root("enter");
let mut pg = Playground::open(root.clone()).unwrap();
assert!(pg.root().exists());
assert_eq!(pg.cwd(), pg.root());
let dir = pg.enter_lesson("file-mgmt", &sample_setup()).unwrap();
assert!(dir.ends_with("file-mgmt"));
assert_eq!(pg.cwd(), dir.as_path());
assert_eq!(fs::read_to_string(dir.join("notes.txt")).unwrap(), "starter\n");
assert_eq!(
fs::read_to_string(dir.join("logs/server.log")).unwrap(),
"line1\nline2\n"
);
assert_eq!(pg.display_cwd(), "~/ArcAcademy/playground/file-mgmt");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn test_reenter_preserves_learner_changes_but_reset_wipes() {
let root = temp_root("reset");
let mut pg = Playground::open(root.clone()).unwrap();
let dir = pg.enter_lesson("file-viewing", &sample_setup()).unwrap();
fs::write(dir.join("notes.txt"), "my edits\n").unwrap();
fs::write(dir.join("extra.txt"), "keep?\n").unwrap();
pg.enter_lesson("file-viewing", &sample_setup()).unwrap();
assert_eq!(fs::read_to_string(dir.join("notes.txt")).unwrap(), "my edits\n");
assert!(dir.join("extra.txt").exists());
pg.reset_lesson().unwrap();
assert_eq!(fs::read_to_string(dir.join("notes.txt")).unwrap(), "starter\n");
assert!(!dir.join("extra.txt").exists());
assert!(dir.join("logs/server.log").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn test_reset_without_lesson_errors() {
let root = temp_root("reset-none");
let mut pg = Playground::open(root.clone()).unwrap();
assert!(pg.reset_lesson().is_err());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn test_change_directory_stays_contained() {
let root = temp_root("cd");
let mut pg = Playground::open(root.clone()).unwrap();
pg.enter_lesson("nav", &sample_setup()).unwrap();
pg.change_directory("logs").unwrap();
assert!(pg.display_cwd().ends_with("nav/logs"));
pg.change_directory("..").unwrap();
assert!(pg.display_cwd().ends_with("nav"));
pg.change_directory("..").unwrap();
assert_eq!(pg.cwd(), pg.root());
assert!(pg.change_directory("..").is_err());
assert_eq!(pg.cwd(), pg.root());
assert!(pg.change_directory("/etc").is_err());
assert!(pg.change_directory("../..").is_err());
assert!(pg.change_directory("nope").is_err());
pg.change_directory("").unwrap();
assert!(pg.display_cwd().ends_with("nav"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn test_unsafe_setup_paths_rejected() {
let root = temp_root("unsafe");
let mut pg = Playground::open(root.clone()).unwrap();
let evil = vec![SetupFile {
path: "../escape.txt".to_string(),
contents: "nope".to_string(),
}];
assert!(pg.enter_lesson("evil", &evil).is_err());
assert!(!root.join("escape.txt").exists());
let abs = vec![SetupFile {
path: "/tmp/abs-escape.txt".to_string(),
contents: "nope".to_string(),
}];
assert!(pg.enter_lesson("evil2", &abs).is_err());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn test_invalid_lesson_id_rejected() {
let root = temp_root("badid");
let mut pg = Playground::open(root.clone()).unwrap();
assert!(pg.enter_lesson("../outside", &[]).is_err());
assert!(pg.enter_lesson("", &[]).is_err());
assert!(pg.enter_lesson("a/b", &[]).is_err());
let _ = fs::remove_dir_all(&root);
}
}
impl crate::lesson::PracticeWorld for Playground {
fn locate(&self, lesson_path: &str) -> Option<PathBuf> {
let base = match &self.lesson_id {
Some(id) => self.root.join(id),
None => self.root.clone(),
};
let rel = Path::new(lesson_path.trim_start_matches('/'));
if rel.components().any(|c| !matches!(c, Component::Normal(_))) {
return None;
}
Some(base.join(rel))
}
fn cwd_relative(&self) -> String {
let base = match &self.lesson_id {
Some(id) => self.root.join(id),
None => self.root.clone(),
};
self.cwd
.strip_prefix(&base)
.map(|rel| rel.to_string_lossy().to_string())
.unwrap_or_default()
}
}