Skip to main content

ralph/commands/tutorial/
sandbox.rs

1//! Tutorial sandbox creation helpers.
2//!
3//! Purpose:
4//! - Build the disposable git-backed project used by the interactive tutorial.
5//!
6//! Responsibilities:
7//! - Create a temporary directory outside the current repository.
8//! - Seed the sandbox with sample Rust project files and `.gitignore`.
9//! - Initialize and configure git using managed subprocess execution.
10//! - Support automatic cleanup or explicit preservation via `--keep-sandbox`.
11//!
12//! Scope:
13//! - Tutorial sandbox filesystem and git bootstrap only.
14//!
15//! Usage:
16//! - Called by the tutorial workflow before guided CLI steps begin.
17//!
18//! Invariants/assumptions:
19//! - The sandbox must be a valid git repository before the tutorial continues.
20//! - Git command failures must surface as hard errors instead of being ignored.
21//! - Preserved sandboxes must not be deleted on drop.
22
23use anyhow::{Context, Result};
24use std::path::{Path, PathBuf};
25use tempfile::TempDir;
26
27use crate::runutil::{ManagedCommand, TimeoutClass, execute_checked_command};
28
29/// Sample Rust project files for tutorial sandbox.
30const SAMPLE_CARGO_TOML: &str = r#"[package]
31name = "tutorial-project"
32version = "0.1.0"
33edition = "2021"
34
35[dependencies]
36"#;
37
38const SAMPLE_LIB_RS: &str = r#"//! Tutorial project for Ralph onboarding.
39//!
40//! Add your code here.
41
42/// Returns a greeting message.
43pub fn greet(name: &str) -> String {
44    format!("Hello, {}!", name)
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    #[test]
52    fn test_greet() {
53        assert_eq!(greet("World"), "Hello, World!");
54    }
55}
56"#;
57
58fn run_tutorial_git(path: &Path, description: &str, args: &[&str]) -> Result<()> {
59    let mut command = std::process::Command::new("git");
60    command.current_dir(path).args(args);
61
62    execute_checked_command(ManagedCommand::new(command, description, TimeoutClass::Git))
63        .with_context(|| format!("{description} in tutorial sandbox {}", path.display()))?;
64    Ok(())
65}
66
67/// A tutorial sandbox with automatic or manual cleanup.
68pub struct TutorialSandbox {
69    /// The temp directory (None if preserved).
70    temp_dir: Option<TempDir>,
71    /// The sandbox path (always available).
72    pub path: PathBuf,
73}
74
75impl TutorialSandbox {
76    /// Create a new tutorial sandbox with git init and sample files.
77    pub fn create() -> Result<Self> {
78        let temp_dir =
79            TempDir::new().context("failed to create temp directory for tutorial sandbox")?;
80        let path = temp_dir.path().to_path_buf();
81
82        run_tutorial_git(
83            &path,
84            "initialize tutorial git repository",
85            &["init", "--quiet"],
86        )?;
87        run_tutorial_git(
88            &path,
89            "configure tutorial git user.name",
90            &["config", "user.name", "Ralph Tutorial"],
91        )?;
92        run_tutorial_git(
93            &path,
94            "configure tutorial git user.email",
95            &["config", "user.email", "tutorial@ralph.invalid"],
96        )?;
97
98        // Create sample project files
99        std::fs::write(path.join("Cargo.toml"), SAMPLE_CARGO_TOML)?;
100        std::fs::create_dir_all(path.join("src"))?;
101        std::fs::write(path.join("src/lib.rs"), SAMPLE_LIB_RS)?;
102
103        // Create .gitignore
104        std::fs::write(
105            path.join(".gitignore"),
106            "/target\n.ralph/lock\n.ralph/cache/\n.ralph/logs/\n",
107        )?;
108
109        run_tutorial_git(&path, "stage tutorial sandbox files", &["add", "."])?;
110        run_tutorial_git(
111            &path,
112            "create tutorial sandbox initial commit",
113            &["commit", "--quiet", "-m", "Initial commit"],
114        )?;
115
116        Ok(Self {
117            temp_dir: Some(temp_dir),
118            path,
119        })
120    }
121
122    /// Keep the sandbox directory (don't delete on drop).
123    pub fn preserve(mut self) -> PathBuf {
124        let path = self.path.clone();
125        // Take the temp_dir out and keep it to prevent cleanup
126        if let Some(temp_dir) = self.temp_dir.take() {
127            // Keep the directory (ignoring the Result)
128            let _ = temp_dir.keep();
129        }
130        path
131    }
132}
133
134impl Drop for TutorialSandbox {
135    fn drop(&mut self) {
136        // temp_dir auto-cleans when dropped (if not None)
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[cfg(unix)]
145    fn write_fake_git(bin_dir: &Path, script: &str) {
146        use std::os::unix::fs::PermissionsExt;
147
148        let path = bin_dir.join("git");
149        std::fs::write(&path, script).expect("write fake git");
150        let mut perms = std::fs::metadata(&path)
151            .expect("fake git metadata")
152            .permissions();
153        perms.set_mode(0o755);
154        std::fs::set_permissions(&path, perms).expect("chmod fake git");
155    }
156
157    #[test]
158    fn sandbox_creates_files() {
159        let _path_guard = crate::testsupport::path::path_lock()
160            .lock()
161            .expect("path lock");
162        let sandbox = TutorialSandbox::create().unwrap();
163
164        assert!(sandbox.path.join("Cargo.toml").exists());
165        assert!(sandbox.path.join("src/lib.rs").exists());
166        assert!(sandbox.path.join(".gitignore").exists());
167        assert!(sandbox.path.join(".git").exists());
168    }
169
170    #[cfg(unix)]
171    #[test]
172    fn sandbox_create_fails_when_git_configuration_fails() {
173        let temp = TempDir::new().unwrap();
174        let bin_dir = temp.path().join("bin");
175        std::fs::create_dir_all(&bin_dir).unwrap();
176        write_fake_git(
177            &bin_dir,
178            r#"#!/bin/sh
179if [ "$1" = "init" ]; then
180  exit 0
181fi
182if [ "$1" = "config" ] && [ "$2" = "user.name" ]; then
183  echo "fake git failing: config" >&2
184  exit 7
185fi
186exit 0
187"#,
188        );
189
190        let err =
191            match crate::testsupport::path::with_prepend_path(&bin_dir, TutorialSandbox::create) {
192                Ok(_) => panic!("sandbox creation should fail when git config fails"),
193                Err(err) => err,
194            };
195        let text = format!("{err:#}");
196        assert!(text.contains("configure tutorial git user.name"));
197        assert!(text.contains("fake git failing: config"));
198    }
199
200    #[test]
201    fn sandbox_preserve_prevents_cleanup() {
202        let _path_guard = crate::testsupport::path::path_lock()
203            .lock()
204            .expect("path lock");
205        let sandbox = TutorialSandbox::create().unwrap();
206        let path = sandbox.preserve();
207
208        // Path should still exist after preserve
209        assert!(path.exists());
210
211        // Clean up manually
212        let _ = std::fs::remove_dir_all(&path);
213    }
214}