🥇 export from upstream (be15b0d)

This commit is contained in:
sandcage-export
2026-05-21 23:15:51 +02:00
commit 29225e08fb
19 changed files with 3254 additions and 0 deletions
+480
View File
@@ -0,0 +1,480 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use figment::providers::{Format, Serialized, Toml};
use figment::Figment;
use miette::Diagnostic;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error, Diagnostic)]
pub enum ConfigError {
#[error("Failed to read config file {0}: {1}")]
#[diagnostic(code(sandcage::config::read_failed))]
ReadFailed(PathBuf, #[source] std::io::Error),
#[error("Failed to parse YAML in {0}: {1}")]
#[diagnostic(code(sandcage::config::parse_failed))]
ParseFailed(PathBuf, #[source] serde_yaml::Error),
#[error("Failed to parse YAML: {0}")]
#[diagnostic(code(sandcage::config::parse_failed_str))]
ParseFailedStr(#[source] serde_yaml::Error),
#[error("Failed to merge configuration layers: {0}")]
#[diagnostic(code(sandcage::config::merge_failed))]
MergeFailed(#[source] figment::Error),
}
pub type Result<T> = std::result::Result<T, ConfigError>;
// ---------------------------------------------------------------------------
// Raw deserialization type — captures unknown keys
// ---------------------------------------------------------------------------
/// Intermediate representation that keeps leftover keys so we can warn about them.
#[derive(Debug, Deserialize)]
struct RawConfig {
#[serde(default)]
env: Option<HashMap<String, String>>,
#[serde(default)]
packages: Option<Vec<String>>,
#[serde(default)]
toolchains: Option<HashMap<String, String>>,
#[serde(default)]
mounts: Option<Vec<String>>,
#[serde(default)]
shell: Option<String>,
#[serde(default)]
justfile: Option<PathBuf>,
/// Absorb everything else so we can warn about unknown fields.
#[serde(flatten)]
extra: HashMap<String, serde_yaml::Value>,
}
// ---------------------------------------------------------------------------
// Public config struct
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SandcageConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub packages: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub toolchains: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mounts: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shell: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justfile: Option<PathBuf>,
}
// ---------------------------------------------------------------------------
// Known keys — used to filter the flattened `extra` map
// ---------------------------------------------------------------------------
const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile"];
// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------
/// Returns `true` if a mount string is well-formed (must contain `:`).
fn is_valid_mount(mount: &str) -> bool {
mount.contains(':')
}
/// Validate and warn; does not return an error — warnings are non-fatal.
fn validate_and_warn(raw: &RawConfig) {
// Warn about unknown top-level keys
for key in raw.extra.keys() {
if !KNOWN_KEYS.contains(&key.as_str()) {
eprintln!(
"sandcage: warning: unknown config key '{key}' will be ignored. \
Known keys: {}",
KNOWN_KEYS.join(", ")
);
}
}
// Validate mount strings
if let Some(mounts) = &raw.mounts {
for mount in mounts {
if !is_valid_mount(mount) {
eprintln!(
"sandcage: warning: mount '{mount}' is missing ':' separator. \
Expected format: <source>:<target> or <source>:<target>:<options>"
);
}
}
}
}
// ---------------------------------------------------------------------------
// Conversion from raw to public struct
// ---------------------------------------------------------------------------
fn from_raw(raw: RawConfig) -> SandcageConfig {
SandcageConfig {
env: raw.env,
packages: raw.packages,
toolchains: raw.toolchains,
mounts: raw.mounts,
shell: raw.shell,
justfile: raw.justfile,
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Parse a `.sandcage.yml` from the given path.
pub fn load(path: &Path) -> Result<SandcageConfig> {
let content = std::fs::read_to_string(path)
.map_err(|e| ConfigError::ReadFailed(path.to_path_buf(), e))?;
let raw: RawConfig = serde_yaml::from_str(&content)
.map_err(|e| ConfigError::ParseFailed(path.to_path_buf(), e))?;
validate_and_warn(&raw);
Ok(from_raw(raw))
}
/// Parse a `.sandcage.yml` from an in-memory string (useful for testing).
pub fn load_from_str(content: &str) -> Result<SandcageConfig> {
// Empty / whitespace-only content → all fields None
if content.trim().is_empty() {
return Ok(SandcageConfig::default());
}
let raw: RawConfig =
serde_yaml::from_str(content).map_err(ConfigError::ParseFailedStr)?;
validate_and_warn(&raw);
Ok(from_raw(raw))
}
// ---------------------------------------------------------------------------
// Layered config resolution via figment
// ---------------------------------------------------------------------------
/// Resolve the final `SandcageConfig` by merging all configuration layers in order:
///
/// 1. Compiled defaults (all `None`)
/// 2. Global config from `global_config_path` (TOML, e.g. `~/.sandcage/config.toml`)
/// 3. Project config from `project_config_path` (YAML, e.g. `.sandcage.yml`)
///
/// Later layers win: project values override global values, which override defaults.
///
/// Pass `None` for either path to skip that layer (e.g. when the file doesn't exist).
pub fn resolve_config(
global_config_path: Option<&Path>,
project_config_path: Option<&Path>,
) -> Result<SandcageConfig> {
// Start with compiled defaults — all fields None.
let mut figment = Figment::from(Serialized::defaults(SandcageConfig::default()));
// Layer 2: global TOML config (if provided).
if let Some(global_path) = global_config_path {
if global_path.exists() {
figment = figment.merge(Toml::file(global_path));
}
}
// Layer 3: project YAML config (if provided).
// figment has no built-in YAML provider, so we parse with serde_yaml first
// and inject via Serialized.
if let Some(project_path) = project_config_path {
if project_path.exists() {
let project_cfg = load(project_path)?;
figment = figment.merge(Serialized::defaults(project_cfg));
}
}
figment.extract().map_err(ConfigError::MergeFailed)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
const FULL_CONFIG: &str = r#"
env:
RUST_LOG: debug
NODE_ENV: development
packages:
- ripgrep
- fd-find
toolchains:
rust: "1.78"
node: "20"
mounts:
- /data/models:/models:ro
- /tmp/cache:/cache
shell: zsh
justfile: ./project.justfile
"#;
// -----------------------------------------------------------------------
// Happy-path tests
// -----------------------------------------------------------------------
#[test]
fn parse_full_config() {
let cfg = load_from_str(FULL_CONFIG).expect("parse full config");
// env
let env = cfg.env.expect("env should be Some");
assert_eq!(env.get("RUST_LOG").map(String::as_str), Some("debug"));
assert_eq!(env.get("NODE_ENV").map(String::as_str), Some("development"));
// packages
let pkgs = cfg.packages.expect("packages should be Some");
assert_eq!(pkgs, vec!["ripgrep", "fd-find"]);
// toolchains
let tc = cfg.toolchains.expect("toolchains should be Some");
assert_eq!(tc.get("rust").map(String::as_str), Some("1.78"));
assert_eq!(tc.get("node").map(String::as_str), Some("20"));
// mounts
let mounts = cfg.mounts.expect("mounts should be Some");
assert_eq!(mounts[0], "/data/models:/models:ro");
assert_eq!(mounts[1], "/tmp/cache:/cache");
// shell
assert_eq!(cfg.shell.as_deref(), Some("zsh"));
// justfile
assert_eq!(cfg.justfile, Some(PathBuf::from("./project.justfile")));
}
#[test]
fn parse_minimal_config_only_env() {
let yaml = "env:\n KEY: value\n";
let cfg = load_from_str(yaml).expect("parse minimal config");
let env = cfg.env.expect("env should be Some");
assert_eq!(env.get("KEY").map(String::as_str), Some("value"));
assert!(cfg.packages.is_none());
assert!(cfg.toolchains.is_none());
assert!(cfg.mounts.is_none());
assert!(cfg.shell.is_none());
assert!(cfg.justfile.is_none());
}
#[test]
fn parse_empty_file_all_fields_none() {
let cfg = load_from_str("").expect("parse empty file");
assert!(cfg.env.is_none());
assert!(cfg.packages.is_none());
assert!(cfg.toolchains.is_none());
assert!(cfg.mounts.is_none());
assert!(cfg.shell.is_none());
assert!(cfg.justfile.is_none());
}
#[test]
fn parse_whitespace_only_file() {
let cfg = load_from_str(" \n\n \t\n").expect("parse whitespace-only file");
assert!(cfg.env.is_none());
}
#[test]
fn load_from_file() {
let mut tmp = NamedTempFile::new().expect("create tmpfile");
write!(tmp, "{FULL_CONFIG}").expect("write tmpfile");
let cfg = load(tmp.path()).expect("load from file");
assert!(cfg.env.is_some());
}
#[test]
fn load_missing_file_returns_error() {
let result = load(Path::new("/nonexistent/path/.sandcage.yml"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ConfigError::ReadFailed(..)));
}
// -----------------------------------------------------------------------
// Error / warning path tests
// -----------------------------------------------------------------------
#[test]
fn invalid_yaml_returns_parse_error() {
let bad_yaml = "env: [this: is: not: valid\n yaml:";
let result = load_from_str(bad_yaml);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(err, ConfigError::ParseFailedStr(..)),
"expected ParseFailedStr, got: {err:?}"
);
}
#[test]
fn unknown_keys_are_absorbed_without_hard_error() {
// Unknown keys should not cause a parse failure — they emit warnings.
let yaml = "env:\n KEY: val\nunknown_field: surprise\n";
let result = load_from_str(yaml);
assert!(
result.is_ok(),
"unknown keys should not cause a hard error: {result:?}"
);
let cfg = result.unwrap();
// Legitimate fields still parsed
assert!(cfg.env.is_some());
}
#[test]
fn invalid_mount_no_colon_does_not_hard_error() {
// A mount without ':' is a warning, not an error.
let yaml = "mounts:\n - /data/models\n - /valid:/mount\n";
let result = load_from_str(yaml);
assert!(result.is_ok(), "invalid mount should not be a hard error");
let cfg = result.unwrap();
let mounts = cfg.mounts.unwrap();
assert_eq!(mounts.len(), 2);
}
#[test]
fn is_valid_mount_helper() {
assert!(is_valid_mount("/src:/dst"));
assert!(is_valid_mount("/src:/dst:ro"));
assert!(!is_valid_mount("/no_colon_here"));
assert!(!is_valid_mount(""));
}
#[test]
fn all_valid_mounts_are_accepted() {
let yaml = "mounts:\n - /data:/mnt/data\n - /tmp:/tmp:ro\n";
let cfg = load_from_str(yaml).unwrap();
let mounts = cfg.mounts.unwrap();
assert!(mounts.iter().all(|m| is_valid_mount(m)));
}
#[test]
fn extra_fields_detected() {
// Verify the `extra` map in RawConfig captures unknown keys correctly.
let yaml = "env:\n A: 1\nfoo: bar\nbaz: 42\n";
let raw: RawConfig = serde_yaml::from_str(yaml).unwrap();
assert!(raw.extra.contains_key("foo"), "extra should contain 'foo'");
assert!(raw.extra.contains_key("baz"), "extra should contain 'baz'");
assert!(!raw.extra.contains_key("env"), "env should not be in extra");
}
// -----------------------------------------------------------------------
// resolve_config layered merge tests
// -----------------------------------------------------------------------
#[test]
fn resolve_defaults_only_all_none() {
let cfg = resolve_config(None, None).expect("resolve with no files");
assert!(cfg.env.is_none());
assert!(cfg.packages.is_none());
assert!(cfg.toolchains.is_none());
assert!(cfg.mounts.is_none());
assert!(cfg.shell.is_none());
assert!(cfg.justfile.is_none());
}
#[test]
fn resolve_global_config_only() {
let toml_content = r#"
shell = "zsh"
[env]
EDITOR = "vim"
"#;
let mut global_tmp = NamedTempFile::new().expect("create global tmpfile");
write!(global_tmp, "{toml_content}").expect("write global tmpfile");
let cfg = resolve_config(Some(global_tmp.path()), None).expect("resolve global only");
assert_eq!(cfg.shell.as_deref(), Some("zsh"));
let env = cfg.env.expect("env should be Some from global");
assert_eq!(env.get("EDITOR").map(String::as_str), Some("vim"));
assert!(cfg.packages.is_none());
}
#[test]
fn resolve_project_config_only() {
let yaml_content = "shell: bash\npackages:\n - ripgrep\n";
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
write!(project_tmp, "{yaml_content}").expect("write project tmpfile");
let cfg = resolve_config(None, Some(project_tmp.path())).expect("resolve project only");
assert_eq!(cfg.shell.as_deref(), Some("bash"));
let pkgs = cfg.packages.expect("packages should be Some");
assert_eq!(pkgs, vec!["ripgrep"]);
assert!(cfg.env.is_none());
}
#[test]
fn resolve_project_overrides_global() {
// Global sets shell=zsh; project sets shell=bash — project wins.
let global_toml = r#"shell = "zsh""#;
let project_yaml = "shell: bash\n";
let mut global_tmp = NamedTempFile::new().expect("create global tmpfile");
write!(global_tmp, "{global_toml}").expect("write global tmpfile");
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
write!(project_tmp, "{project_yaml}").expect("write project tmpfile");
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()))
.expect("resolve both");
assert_eq!(cfg.shell.as_deref(), Some("bash"), "project shell should override global");
}
#[test]
fn resolve_partial_overlap_fields_merge() {
// Global sets shell + env; project sets packages only.
// Expect: shell from global, env from global, packages from project.
let global_toml = "shell = \"zsh\"\n[env]\nEDITOR = \"vim\"\n";
let project_yaml = "packages:\n - fd-find\n";
let mut global_tmp = NamedTempFile::new().expect("create global tmpfile");
write!(global_tmp, "{global_toml}").expect("write global tmpfile");
let mut project_tmp = NamedTempFile::new().expect("create project tmpfile");
write!(project_tmp, "{project_yaml}").expect("write project tmpfile");
let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path()))
.expect("resolve partial overlap");
assert_eq!(cfg.shell.as_deref(), Some("zsh"), "shell should come from global");
let env = cfg.env.expect("env should be Some from global");
assert_eq!(env.get("EDITOR").map(String::as_str), Some("vim"));
let pkgs = cfg.packages.expect("packages should be Some from project");
assert_eq!(pkgs, vec!["fd-find"]);
}
#[test]
fn resolve_nonexistent_paths_skipped() {
// Paths that don't exist should be silently skipped (not errors).
let cfg = resolve_config(
Some(Path::new("/nonexistent/config.toml")),
Some(Path::new("/nonexistent/.sandcage.yml")),
)
.expect("nonexistent files should not error");
assert!(cfg.shell.is_none());
}
}
+604
View File
@@ -0,0 +1,604 @@
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use miette::Diagnostic;
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::config::SandcageConfig;
// ---------------------------------------------------------------------------
// Bundled Dockerfiles
// ---------------------------------------------------------------------------
const DOCKERFILE_BASE: &str = include_str!("../images/base/Dockerfile");
const DOCKERFILE_CLAUDE: &str = include_str!("../images/claude/Dockerfile");
const DOCKERFILE_CODEX: &str = include_str!("../images/codex/Dockerfile");
const COMPOSE_YAML: &str = include_str!("../compose/docker-compose.yml");
#[derive(Debug, Error, Diagnostic)]
pub enum DockerError {
#[error("docker executable not found in PATH")]
#[diagnostic(
code(sandcage::docker::not_found),
help("Install Docker: https://docs.docker.com/get-docker/")
)]
DockerNotFound,
#[error("docker compose plugin not available")]
#[diagnostic(
code(sandcage::docker::compose_not_found),
help("Docker Compose V2 is required. Install it via Docker Desktop or 'docker compose' plugin.")
)]
ComposeNotFound,
#[error("Failed to determine current user UID/GID: {0}")]
#[diagnostic(code(sandcage::docker::id_failed))]
IdFailed(String),
#[error("Cannot determine home directory")]
#[diagnostic(code(sandcage::docker::no_home_dir))]
NoHomeDir,
#[error("Failed to write temporary compose file: {0}")]
#[diagnostic(code(sandcage::docker::tempfile_failed))]
TempfileFailed(#[source] std::io::Error),
#[error("Failed to spawn docker compose: {0}")]
#[diagnostic(code(sandcage::docker::spawn_failed))]
SpawnFailed(#[source] std::io::Error),
#[error("Service '{service}' exited with status {code}")]
#[diagnostic(code(sandcage::docker::service_failed))]
ServiceFailed { service: String, code: i32 },
#[error("Failed to create temporary build directory: {0}")]
#[diagnostic(code(sandcage::docker::temp_dir_failed))]
TempDirFailed(#[source] std::io::Error),
#[error("Failed to write Dockerfile to temp directory: {0}")]
#[diagnostic(code(sandcage::docker::dockerfile_write_failed))]
DockerfileWriteFailed(#[source] std::io::Error),
#[error("Failed to read build-hashes file: {0}")]
#[diagnostic(code(sandcage::docker::hash_read_failed))]
HashReadFailed(#[source] std::io::Error),
#[error("Failed to write build-hashes file: {0}")]
#[diagnostic(code(sandcage::docker::hash_write_failed))]
HashWriteFailed(#[source] std::io::Error),
#[error("docker build for '{image}' exited with status {code}")]
#[diagnostic(code(sandcage::docker::build_failed))]
BuildFailed { image: String, code: i32 },
#[error("Image '{image}' not found locally")]
#[diagnostic(
code(sandcage::docker::image_not_found),
help("Run `sandcage build` to build the required images.")
)]
ImageNotFound { image: String },
}
pub type Result<T> = std::result::Result<T, DockerError>;
fn require_docker() -> Result<PathBuf> {
which::which("docker").map_err(|_| DockerError::DockerNotFound)
}
fn require_compose(docker: &Path) -> Result<()> {
let output = Command::new(docker)
.args(["compose", "version"])
.output()
.map_err(|_| DockerError::ComposeNotFound)?;
if !output.status.success() {
return Err(DockerError::ComposeNotFound);
}
Ok(())
}
fn id_flag(flag: &str) -> Result<String> {
let output = Command::new("id")
.arg(flag)
.output()
.map_err(|e| DockerError::IdFailed(e.to_string()))?;
if !output.status.success() {
return Err(DockerError::IdFailed(format!(
"id {flag} exited with {}",
output.status
)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn build_compose_env(workspace: &Path) -> Result<HashMap<String, String>> {
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
let sandcage_home = home.join(".sandcage");
let uid = id_flag("-u")?;
let gid = id_flag("-g")?;
let mut env = HashMap::new();
env.insert("SANDCAGE_UID".into(), uid);
env.insert("SANDCAGE_GID".into(), gid);
env.insert(
"SANDCAGE_WORKSPACE".into(),
workspace.to_string_lossy().into_owned(),
);
env.insert(
"SANDCAGE_HOME".into(),
sandcage_home.to_string_lossy().into_owned(),
);
env.insert(
"SANDCAGE_GLOBAL_JUSTFILE".into(),
sandcage_home
.join("Justfile")
.to_string_lossy()
.into_owned(),
);
Ok(env)
}
fn write_compose_tempfile() -> Result<tempfile::NamedTempFile> {
let mut tmp = tempfile::Builder::new()
.prefix("sandcage-compose-")
.suffix(".yml")
.tempfile()
.map_err(DockerError::TempfileFailed)?;
tmp.write_all(COMPOSE_YAML.as_bytes())
.map_err(DockerError::TempfileFailed)?;
tmp.flush().map_err(DockerError::TempfileFailed)?;
Ok(tmp)
}
fn image_for_service(service: &str) -> &'static str {
match service {
"claude" => "sandcage-claude",
"codex" => "sandcage-codex",
_ => "sandcage-base",
}
}
fn require_image(docker: &Path, image: &str) -> Result<()> {
let output = Command::new(docker)
.args(["image", "inspect", image])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match output {
Ok(status) if status.success() => Ok(()),
_ => Err(DockerError::ImageNotFound {
image: image.to_string(),
}),
}
}
pub fn run_service(service: &str, workspace: &Path, config: &SandcageConfig) -> Result<()> {
let docker = require_docker()?;
require_compose(&docker)?;
let image = image_for_service(service);
require_image(&docker, image)?;
let compose_file = write_compose_tempfile()?;
let compose_path = compose_file.path().to_string_lossy().into_owned();
let compose_env = build_compose_env(workspace)?;
// Build the command
let mut cmd = Command::new(&docker);
cmd.args(["compose", "-f", &compose_path, "run", "--rm"]);
// Add extra -e flags from the merged config env map
if let Some(ref env_map) = config.env {
for (key, value) in env_map {
cmd.args(["-e", &format!("{key}={value}")]);
}
}
cmd.arg(service);
// Inject compose environment variables
cmd.envs(&compose_env);
// Inherit stdio for interactive use
cmd.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
let status = cmd.status().map_err(DockerError::SpawnFailed)?;
if !status.success() {
return Err(DockerError::ServiceFailed {
service: service.to_string(),
code: status.code().unwrap_or(-1),
});
}
Ok(())
}
// ---------------------------------------------------------------------------
// Build with cache awareness
// ---------------------------------------------------------------------------
/// SHA-256 hex digest of the given string content.
fn sha256_hex(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
hex::encode(hasher.finalize())
}
/// Path to the build-hashes file: `~/.sandcage/.build-hashes`.
fn build_hashes_path() -> Result<PathBuf> {
let home = dirs::home_dir().ok_or(DockerError::NoHomeDir)?;
Ok(home.join(".sandcage").join(".build-hashes"))
}
/// Read the stored hashes from `~/.sandcage/.build-hashes`.
/// Returns an empty map if the file does not exist.
fn read_stored_hashes(path: &Path) -> Result<HashMap<String, String>> {
if !path.exists() {
return Ok(HashMap::new());
}
let content = std::fs::read_to_string(path).map_err(DockerError::HashReadFailed)?;
let mut map = HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((name, hash)) = line.split_once(':') {
map.insert(name.to_string(), hash.to_string());
}
}
Ok(map)
}
/// Persist the full hashes map back to the file.
fn write_stored_hashes(path: &Path, hashes: &HashMap<String, String>) -> Result<()> {
// Ensure parent directory exists.
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(DockerError::HashWriteFailed)?;
}
// Build deterministic output (sorted keys).
let mut lines: Vec<String> = hashes
.iter()
.map(|(k, v)| format!("{k}:{v}"))
.collect();
lines.sort();
let content = lines.join("\n") + "\n";
std::fs::write(path, content).map_err(DockerError::HashWriteFailed)?;
Ok(())
}
/// Build a single image.
/// Writes the `dockerfile_content` to a temp directory, then invokes
/// `docker build -t <image>:latest <temp_dir>`.
fn build_one_image(docker: &Path, image: &str, dockerfile_content: &str) -> Result<()> {
let tmp_dir = tempfile::Builder::new()
.prefix("sandcage-build-")
.tempdir()
.map_err(DockerError::TempDirFailed)?;
let dockerfile_path = tmp_dir.path().join("Dockerfile");
std::fs::write(&dockerfile_path, dockerfile_content)
.map_err(DockerError::DockerfileWriteFailed)?;
let status = Command::new(docker)
.args(["build", "-t", &format!("{image}:latest")])
.arg(tmp_dir.path())
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.map_err(DockerError::SpawnFailed)?;
if !status.success() {
return Err(DockerError::BuildFailed {
image: image.to_string(),
code: status.code().unwrap_or(-1),
});
}
Ok(())
}
/// Build all three sandcage images with cache awareness.
///
/// * When `force` is `true`, every image is rebuilt regardless of the stored hash.
/// * Otherwise, an image is skipped when its Dockerfile hash matches the stored value.
/// * After a successful build the hash is updated in `~/.sandcage/.build-hashes`.
pub fn build_images(force: bool) -> Result<()> {
let docker = require_docker()?;
let images: &[(&str, &str)] = &[
("sandcage-base", DOCKERFILE_BASE),
("sandcage-claude", DOCKERFILE_CLAUDE),
("sandcage-codex", DOCKERFILE_CODEX),
];
let hashes_path = build_hashes_path()?;
let mut stored = read_stored_hashes(&hashes_path)?;
for (image, dockerfile) in images {
let current_hash = sha256_hex(dockerfile);
let needs_build = force || stored.get(*image).map_or(true, |h| h != &current_hash);
if needs_build {
if force {
eprintln!("{image}: rebuilding (--force)");
} else {
eprintln!("{image}: rebuilding (Dockerfile changed)");
}
build_one_image(&docker, image, dockerfile)?;
stored.insert(image.to_string(), current_hash);
write_stored_hashes(&hashes_path, &stored)?;
} else {
eprintln!("{image}: up to date");
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn compose_yaml_is_bundled() {
assert!(
COMPOSE_YAML.contains("services:"),
"bundled compose YAML should contain 'services:'"
);
assert!(
COMPOSE_YAML.contains("claude:"),
"bundled compose YAML should define 'claude' service"
);
assert!(
COMPOSE_YAML.contains("codex:"),
"bundled compose YAML should define 'codex' service"
);
assert!(
COMPOSE_YAML.contains("shell:"),
"bundled compose YAML should define 'shell' service"
);
}
#[test]
fn build_compose_env_contains_required_keys() {
let workspace = PathBuf::from("/tmp/test-workspace");
let env = build_compose_env(&workspace).expect("build_compose_env");
assert!(env.contains_key("SANDCAGE_UID"), "missing SANDCAGE_UID");
assert!(env.contains_key("SANDCAGE_GID"), "missing SANDCAGE_GID");
assert!(env.contains_key("SANDCAGE_WORKSPACE"), "missing SANDCAGE_WORKSPACE");
assert!(env.contains_key("SANDCAGE_HOME"), "missing SANDCAGE_HOME");
assert!(
env.contains_key("SANDCAGE_GLOBAL_JUSTFILE"),
"missing SANDCAGE_GLOBAL_JUSTFILE"
);
}
#[test]
fn build_compose_env_workspace_matches() {
let workspace = PathBuf::from("/my/project");
let env = build_compose_env(&workspace).expect("build_compose_env");
assert_eq!(env["SANDCAGE_WORKSPACE"], "/my/project");
}
#[test]
fn build_compose_env_home_ends_with_sandcage() {
let workspace = PathBuf::from("/tmp");
let env = build_compose_env(&workspace).expect("build_compose_env");
assert!(
env["SANDCAGE_HOME"].ends_with(".sandcage"),
"SANDCAGE_HOME should end with .sandcage, got: {}",
env["SANDCAGE_HOME"]
);
}
#[test]
fn build_compose_env_justfile_is_under_home() {
let workspace = PathBuf::from("/tmp");
let env = build_compose_env(&workspace).expect("build_compose_env");
assert!(
env["SANDCAGE_GLOBAL_JUSTFILE"].starts_with(&env["SANDCAGE_HOME"]),
"SANDCAGE_GLOBAL_JUSTFILE should be under SANDCAGE_HOME"
);
assert!(
env["SANDCAGE_GLOBAL_JUSTFILE"].ends_with("Justfile"),
"SANDCAGE_GLOBAL_JUSTFILE should end with Justfile"
);
}
#[test]
fn uid_gid_are_numeric() {
let workspace = PathBuf::from("/tmp");
let env = build_compose_env(&workspace).expect("build_compose_env");
let uid: u32 = env["SANDCAGE_UID"]
.parse()
.expect("UID should be numeric");
let gid: u32 = env["SANDCAGE_GID"]
.parse()
.expect("GID should be numeric");
// Basic sanity — UIDs/GIDs are non-negative (they're u32)
assert!(uid < 1_000_000, "UID seems unreasonably large: {uid}");
assert!(gid < 1_000_000, "GID seems unreasonably large: {gid}");
}
#[test]
fn write_compose_tempfile_creates_valid_yaml() {
let tmp = write_compose_tempfile().expect("write_compose_tempfile");
let content = std::fs::read_to_string(tmp.path()).expect("read tempfile");
assert_eq!(content, COMPOSE_YAML, "tempfile content should match bundled YAML");
}
#[test]
fn require_docker_finds_docker_or_fails_cleanly() {
// This test documents the behavior — it either finds docker or returns
// DockerNotFound. We don't assert which, since CI may or may not have docker.
let result = require_docker();
match result {
Ok(path) => assert!(path.to_string_lossy().contains("docker")),
Err(DockerError::DockerNotFound) => { /* expected on machines without docker */ }
Err(e) => panic!("unexpected error: {e}"),
}
}
// -----------------------------------------------------------------------
// build_images / cache-awareness tests
// -----------------------------------------------------------------------
#[test]
fn dockerfiles_are_bundled() {
assert!(!DOCKERFILE_BASE.is_empty(), "base Dockerfile should not be empty");
assert!(!DOCKERFILE_CLAUDE.is_empty(), "claude Dockerfile should not be empty");
assert!(!DOCKERFILE_CODEX.is_empty(), "codex Dockerfile should not be empty");
}
#[test]
fn sha256_hex_is_deterministic() {
let content = "FROM ubuntu:22.04\nRUN echo hello\n";
let h1 = sha256_hex(content);
let h2 = sha256_hex(content);
assert_eq!(h1, h2, "sha256_hex must be deterministic");
// SHA-256 produces 64 hex chars
assert_eq!(h1.len(), 64, "sha256_hex should produce 64-char hex string");
}
#[test]
fn sha256_hex_differs_for_different_input() {
let h1 = sha256_hex("FROM ubuntu:22.04\n");
let h2 = sha256_hex("FROM debian:bookworm\n");
assert_ne!(h1, h2, "different content must yield different hash");
}
#[test]
fn hash_file_roundtrip() {
let tmp = tempfile::tempdir().expect("create temp dir");
let path = tmp.path().join(".build-hashes");
let mut hashes = HashMap::new();
hashes.insert("sandcage-base".to_string(), "aabbcc".to_string());
hashes.insert("sandcage-claude".to_string(), "ddeeff".to_string());
write_stored_hashes(&path, &hashes).expect("write hashes");
let loaded = read_stored_hashes(&path).expect("read hashes");
assert_eq!(loaded.get("sandcage-base").map(String::as_str), Some("aabbcc"));
assert_eq!(loaded.get("sandcage-claude").map(String::as_str), Some("ddeeff"));
}
#[test]
fn read_stored_hashes_returns_empty_when_file_missing() {
let tmp = tempfile::tempdir().expect("create temp dir");
let path = tmp.path().join("nonexistent");
let result = read_stored_hashes(&path).expect("read hashes on missing file");
assert!(result.is_empty(), "should return empty map when file does not exist");
}
#[test]
fn cache_hit_detection_same_hash() {
let dockerfile = "FROM ubuntu:22.04\n";
let hash = sha256_hex(dockerfile);
let mut stored: HashMap<String, String> = HashMap::new();
stored.insert("sandcage-base".to_string(), hash.clone());
let needs_build = stored.get("sandcage-base").map_or(true, |h| h != &hash);
assert!(!needs_build, "same hash should be a cache hit (no rebuild needed)");
}
#[test]
fn cache_miss_detection_different_hash() {
let dockerfile = "FROM ubuntu:22.04\n";
let current_hash = sha256_hex(dockerfile);
let mut stored: HashMap<String, String> = HashMap::new();
stored.insert("sandcage-base".to_string(), "old_stale_hash".to_string());
let needs_build = stored.get("sandcage-base").map_or(true, |h| h != &current_hash);
assert!(needs_build, "different hash should be a cache miss (rebuild needed)");
}
#[test]
fn cache_miss_detection_missing_entry() {
let dockerfile = "FROM ubuntu:22.04\n";
let current_hash = sha256_hex(dockerfile);
let stored: HashMap<String, String> = HashMap::new();
let needs_build = stored.get("sandcage-base").map_or(true, |h| h != &current_hash);
assert!(needs_build, "missing entry should be treated as a cache miss");
}
#[test]
fn force_flag_bypasses_cache() {
// Even when hash matches, force=true means needs_build is true
let dockerfile = "FROM ubuntu:22.04\n";
let hash = sha256_hex(dockerfile);
let mut stored: HashMap<String, String> = HashMap::new();
stored.insert("sandcage-base".to_string(), hash.clone());
let force = true;
let needs_build = force || stored.get("sandcage-base").map_or(true, |h| h != &hash);
assert!(needs_build, "force flag should always trigger a rebuild");
}
#[test]
fn write_stored_hashes_creates_parent_dirs() {
let tmp = tempfile::tempdir().expect("create temp dir");
// Nested path that doesn't exist yet
let path = tmp.path().join("a").join("b").join(".build-hashes");
let mut hashes = HashMap::new();
hashes.insert("sandcage-base".to_string(), "abc123".to_string());
write_stored_hashes(&path, &hashes).expect("should create parent dirs and write");
assert!(path.exists(), "hash file should have been created");
}
#[test]
fn image_for_service_maps_correctly() {
assert_eq!(image_for_service("claude"), "sandcage-claude");
assert_eq!(image_for_service("codex"), "sandcage-codex");
assert_eq!(image_for_service("shell"), "sandcage-base");
assert_eq!(image_for_service("anything-else"), "sandcage-base");
}
#[test]
fn require_image_fails_for_nonexistent_image() {
let docker = match require_docker() {
Ok(path) => path,
Err(_) => return, // skip if docker not installed
};
let result = require_image(&docker, "sandcage-nonexistent-image-abc123");
assert!(
matches!(result, Err(DockerError::ImageNotFound { .. })),
"should fail for nonexistent image: {result:?}"
);
}
}
+368
View File
@@ -0,0 +1,368 @@
use std::path::{Path, PathBuf};
use miette::Diagnostic;
use thiserror::Error;
const CLAUDE_SETTINGS_TEMPLATE: &str =
include_str!("../templates/claude/settings.json");
#[derive(Debug, Error, Diagnostic)]
pub enum InitError {
#[error("Failed to create directory {0}: {1}")]
#[diagnostic(code(sandcage::init::create_dir_failed))]
CreateDirFailed(PathBuf, #[source] std::io::Error),
#[error("Failed to write file {0}: {1}")]
#[diagnostic(code(sandcage::init::write_file_failed))]
WriteFileFailed(PathBuf, #[source] std::io::Error),
#[error("Cannot determine home directory")]
#[diagnostic(code(sandcage::init::no_home_dir))]
NoHomeDir,
#[error(".sandcage.yml already exists at {0}; remove it manually to re-initialise")]
#[diagnostic(code(sandcage::init::config_already_exists))]
ConfigAlreadyExists(PathBuf),
}
pub type Result<T> = std::result::Result<T, InitError>;
pub fn preseed() -> Result<()> {
let home = dirs::home_dir().ok_or(InitError::NoHomeDir)?;
preseed_with_home(&home)
}
pub fn preseed_with_home(home: &Path) -> Result<()> {
let sandcage_home = home.join(".sandcage");
// 1. ~/.sandcage/.claude/
let claude_dir = sandcage_home.join(".claude");
create_dir_all(&claude_dir)?;
// 2. ~/.sandcage/.codex/
let codex_dir = sandcage_home.join(".codex");
create_dir_all(&codex_dir)?;
// 3. Seed Claude settings.json from the bundled template
let settings_dest = claude_dir.join("settings.json");
if !settings_dest.exists() {
std::fs::write(&settings_dest, CLAUDE_SETTINGS_TEMPLATE)
.map_err(|e| InitError::WriteFileFailed(settings_dest.clone(), e))?;
println!("sandcage: seeded {} from template", settings_dest.display());
}
// 4. Create an empty Justfile if absent
let justfile = sandcage_home.join("Justfile");
if !justfile.exists() {
std::fs::write(&justfile, "")
.map_err(|e| InitError::WriteFileFailed(justfile.clone(), e))?;
}
Ok(())
}
fn create_dir_all(path: &Path) -> Result<()> {
std::fs::create_dir_all(path)
.map_err(|e| InitError::CreateDirFailed(path.to_path_buf(), e))
}
// ---------------------------------------------------------------------------
// Project scaffolding — `sandcage init`
// ---------------------------------------------------------------------------
/// Language ecosystems we can detect from marker files.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ecosystem {
Rust,
Node,
Python,
Go,
Generic,
}
impl Ecosystem {
fn as_str(self) -> &'static str {
match self {
Ecosystem::Rust => "rust",
Ecosystem::Node => "node",
Ecosystem::Python => "python",
Ecosystem::Go => "go",
Ecosystem::Generic => "generic",
}
}
}
/// Detect the primary language ecosystem from files present in `dir`.
pub fn detect_ecosystem(dir: &Path) -> Ecosystem {
if dir.join("Cargo.toml").exists() {
Ecosystem::Rust
} else if dir.join("package.json").exists() {
Ecosystem::Node
} else if dir.join("pyproject.toml").exists() || dir.join("requirements.txt").exists() {
Ecosystem::Python
} else if dir.join("go.mod").exists() {
Ecosystem::Go
} else {
Ecosystem::Generic
}
}
/// Build the YAML content for a `.sandcage.yml` file.
fn build_yaml(ecosystem: Ecosystem) -> String {
let toolchain_and_packages = match ecosystem {
Ecosystem::Rust => {
"toolchains:\n rust: \"stable\"\n\npackages:\n - ripgrep\n - fd-find\n"
}
Ecosystem::Node => {
"toolchains:\n node: \"20\"\n\npackages:\n - git\n - curl\n"
}
Ecosystem::Python => {
"toolchains:\n python: \"3.12\"\n\npackages:\n - git\n - curl\n"
}
Ecosystem::Go => {
"toolchains:\n go: \"1.22\"\n\npackages:\n - git\n - curl\n"
}
Ecosystem::Generic => {
"packages:\n - git\n - curl\n"
}
};
format!(
"# Sandcage project configuration\n\
# Docs: https://github.com/user/sandcage\n\
\n\
# Detected ecosystem: {ecosystem}\n\
\n\
env:\n\
# EXAMPLE_VAR: value\n\
\n\
{toolchain_and_packages}\
\n\
# mounts:\n\
# - /path/on/host:/path/in/container\n\
\n\
# shell: zsh\n",
ecosystem = ecosystem.as_str(),
)
}
/// Generate a `.sandcage.yml` in `path` with suggested content based on the
/// detected language ecosystem. Returns an error if the file already exists.
pub fn scaffold_project(path: &Path) -> Result<()> {
let config_path = path.join(".sandcage.yml");
if config_path.exists() {
return Err(InitError::ConfigAlreadyExists(config_path));
}
let ecosystem = detect_ecosystem(path);
let yaml = build_yaml(ecosystem);
std::fs::write(&config_path, &yaml)
.map_err(|e| InitError::WriteFileFailed(config_path.clone(), e))?;
println!(
"sandcage: initialised {} (detected ecosystem: {})",
config_path.display(),
ecosystem.as_str(),
);
println!();
print!("{yaml}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn run(home: &Path) -> Result<()> {
preseed_with_home(home)
}
#[test]
fn creates_claude_and_codex_dirs() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
assert!(
home.path().join(".sandcage/.claude").is_dir(),
".claude dir should exist"
);
assert!(
home.path().join(".sandcage/.codex").is_dir(),
".codex dir should exist"
);
}
#[test]
fn seeds_claude_settings_when_absent() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
let settings = home.path().join(".sandcage/.claude/settings.json");
assert!(settings.exists(), "settings.json should be created");
let content = std::fs::read_to_string(&settings).expect("read settings");
assert_eq!(
content.trim(),
CLAUDE_SETTINGS_TEMPLATE.trim(),
"settings.json content should match the template"
);
}
#[test]
fn does_not_overwrite_existing_claude_settings() {
let home = TempDir::new().expect("create tempdir");
// Create the directory and put custom content in settings.json first
let claude_dir = home.path().join(".sandcage/.claude");
std::fs::create_dir_all(&claude_dir).expect("mkdir");
let settings = claude_dir.join("settings.json");
std::fs::write(&settings, r#"{"existing": true}"#).expect("write");
run(home.path()).expect("preseed");
let content = std::fs::read_to_string(&settings).expect("read");
assert_eq!(
content, r#"{"existing": true}"#,
"existing settings.json must not be overwritten"
);
}
#[test]
fn creates_empty_justfile_when_absent() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("preseed");
let justfile = home.path().join(".sandcage/Justfile");
assert!(justfile.exists(), "Justfile should be created");
assert_eq!(
std::fs::read_to_string(&justfile).expect("read"),
"",
"Justfile should be empty"
);
}
#[test]
fn does_not_overwrite_existing_justfile() {
let home = TempDir::new().expect("create tempdir");
// Seed a Justfile with content first
let sandcage_dir = home.path().join(".sandcage");
std::fs::create_dir_all(&sandcage_dir).expect("mkdir");
let justfile = sandcage_dir.join("Justfile");
std::fs::write(&justfile, "default:\n\t@echo hello").expect("write");
run(home.path()).expect("preseed");
let content = std::fs::read_to_string(&justfile).expect("read");
assert_eq!(
content, "default:\n\t@echo hello",
"existing Justfile must not be overwritten"
);
}
#[test]
fn idempotent_second_run() {
let home = TempDir::new().expect("create tempdir");
run(home.path()).expect("first preseed");
run(home.path()).expect("second preseed should also succeed");
// All artefacts should still exist after two runs
assert!(home.path().join(".sandcage/.claude").is_dir());
assert!(home.path().join(".sandcage/.codex").is_dir());
assert!(home.path().join(".sandcage/.claude/settings.json").exists());
assert!(home.path().join(".sandcage/Justfile").exists());
}
// -----------------------------------------------------------------------
// scaffold_project / ecosystem detection tests
// -----------------------------------------------------------------------
fn touch(dir: &Path, name: &str) {
std::fs::write(dir.join(name), "").expect("touch file");
}
#[test]
fn detects_rust_from_cargo_toml() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "Cargo.toml");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Rust);
}
#[test]
fn detects_node_from_package_json() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "package.json");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Node);
}
#[test]
fn detects_python_from_pyproject_toml() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "pyproject.toml");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Python);
}
#[test]
fn detects_python_from_requirements_txt() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "requirements.txt");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Python);
}
#[test]
fn detects_go_from_go_mod() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "go.mod");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Go);
}
#[test]
fn falls_back_to_generic_when_no_markers() {
let dir = TempDir::new().expect("tempdir");
assert_eq!(detect_ecosystem(dir.path()), Ecosystem::Generic);
}
#[test]
fn scaffold_creates_sandcage_yml() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "Cargo.toml");
scaffold_project(dir.path()).expect("scaffold");
assert!(dir.path().join(".sandcage.yml").exists());
}
#[test]
fn scaffold_refuses_to_overwrite_existing_config() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), ".sandcage.yml");
let result = scaffold_project(dir.path());
assert!(
matches!(result, Err(InitError::ConfigAlreadyExists(_))),
"expected ConfigAlreadyExists, got {result:?}"
);
}
#[test]
fn scaffold_generated_yaml_is_valid() {
let dir = TempDir::new().expect("tempdir");
touch(dir.path(), "Cargo.toml");
scaffold_project(dir.path()).expect("scaffold");
let content = std::fs::read_to_string(dir.path().join(".sandcage.yml"))
.expect("read .sandcage.yml");
// Must be parseable as YAML (serde_yaml returns Value on success)
let parsed: serde_yaml::Value =
serde_yaml::from_str(&content).expect("YAML must be valid");
// Confirm expected keys are present
let map = parsed.as_mapping().expect("top-level YAML must be a mapping");
assert!(
map.contains_key(&serde_yaml::Value::String("toolchains".to_string())),
"expected 'toolchains' key in generated YAML"
);
}
}
+106
View File
@@ -0,0 +1,106 @@
use clap::{Parser, Subcommand};
use miette::Diagnostic;
use std::path::PathBuf;
use thiserror::Error;
mod config;
mod docker;
mod init;
mod workspace;
/// Sandboxed containers for AI coding agents
#[derive(Parser, Debug)]
#[command(name = "sandcage", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Run Claude Code agent in a sandboxed container
Claude {
/// Path to the project directory (defaults to current directory)
path: Option<PathBuf>,
},
/// Run Codex agent in a sandboxed container
Codex {
/// Path to the project directory (defaults to current directory)
path: Option<PathBuf>,
},
/// Interactive shell with the same environment
Shell {
/// Path to the project directory (defaults to current directory)
path: Option<PathBuf>,
},
/// Build all container images
Build {
/// Force rebuild even if images are up to date
#[arg(long, short)]
force: bool,
},
/// Initialize a .sandcage.yml for a project
Init,
}
#[derive(Debug, Error, Diagnostic)]
enum AppError {
#[error(transparent)]
#[diagnostic(transparent)]
Workspace(#[from] workspace::WorkspaceError),
#[error(transparent)]
#[diagnostic(transparent)]
Init(#[from] init::InitError),
#[error(transparent)]
#[diagnostic(transparent)]
Config(#[from] config::ConfigError),
#[error(transparent)]
#[diagnostic(transparent)]
Docker(#[from] docker::DockerError),
}
fn run_agent(service: &str, path: Option<PathBuf>) -> std::result::Result<(), AppError> {
// 1. Resolve workspace
let workspace = workspace::resolve_workspace(path.as_deref())?;
eprintln!("sandcage: workspace \u{2192} {}", workspace.display());
// 2. Preseed ~/.sandcage directories
init::preseed()?;
// 3. Resolve layered config
let home = dirs::home_dir().ok_or(init::InitError::NoHomeDir)?;
let global_config = home.join(".sandcage").join("config.toml");
let project_config = workspace.join(".sandcage.yml");
let cfg = config::resolve_config(
Some(&global_config),
Some(&project_config),
)?;
// 4. Run the service
eprintln!("sandcage: service \u{2192} {service}");
docker::run_service(service, &workspace, &cfg)?;
Ok(())
}
fn main() -> miette::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Claude { path } => run_agent("claude", path)?,
Commands::Codex { path } => run_agent("codex", path)?,
Commands::Shell { path } => run_agent("shell", path)?,
Commands::Build { force } => {
docker::build_images(force)?;
}
Commands::Init => {
let workspace = workspace::resolve_workspace(None)?;
init::scaffold_project(&workspace)?;
}
}
Ok(())
}
+114
View File
@@ -0,0 +1,114 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use miette::Diagnostic;
use thiserror::Error;
#[derive(Debug, Error, Diagnostic)]
pub enum WorkspaceError {
#[error("Failed to resolve absolute path for {0}: {1}")]
#[diagnostic(code(sandcage::workspace::canonicalize_failed))]
CanonicalizeFailed(PathBuf, #[source] std::io::Error),
}
pub type Result<T> = std::result::Result<T, WorkspaceError>;
pub fn resolve_workspace(path: Option<&Path>) -> Result<PathBuf> {
// Determine starting path
let raw = match path {
Some(p) => p.to_path_buf(),
None => std::env::current_dir()
.map_err(|e| WorkspaceError::CanonicalizeFailed(PathBuf::from("."), e))?,
};
// Resolve to absolute, canonical path (follows symlinks, checks existence)
let absolute = raw
.canonicalize()
.map_err(|e| WorkspaceError::CanonicalizeFailed(raw.clone(), e))?;
// Ask git for the worktree root; silently fall back if not a git repo
let git_root = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(&absolute)
.output();
match git_root {
Ok(output) if output.status.success() => {
let root = String::from_utf8_lossy(&output.stdout)
.trim()
.to_string();
Ok(PathBuf::from(root))
}
// Not a git repo, or git not installed — return the absolute path as-is
_ => Ok(absolute),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
/// Create a temporary directory and initialise it as a git repository.
/// Returns the `TempDir` guard (keep it alive for the test) and the path.
fn make_git_repo() -> (TempDir, PathBuf) {
let dir = TempDir::new().expect("create tempdir");
let path = dir.path().to_path_buf();
// git init
let status = Command::new("git")
.args(["init"])
.current_dir(&path)
.status()
.expect("run git init");
assert!(status.success(), "git init failed");
(dir, path)
}
#[test]
fn returns_repo_root_when_inside_git_repo() {
let (_guard, repo_root) = make_git_repo();
// Create a nested directory inside the repo
let nested = repo_root.join("a").join("b");
std::fs::create_dir_all(&nested).expect("create nested dirs");
// resolve_workspace from the nested dir should return the repo root
let resolved = resolve_workspace(Some(&nested)).expect("resolve_workspace");
assert_eq!(
resolved.canonicalize().unwrap(),
repo_root.canonicalize().unwrap(),
"expected repo root, got {resolved:?}"
);
}
#[test]
fn returns_directory_itself_when_not_in_git_repo() {
// Create a temp dir that is NOT a git repo (no git init)
let dir = TempDir::new().expect("create tempdir");
// Make sure there is no ancestor git repo that would pollute the result.
// tempfile creates dirs under the system temp folder, which is typically
// outside any user repo, so this should be safe.
let path = dir.path().to_path_buf();
let resolved = resolve_workspace(Some(&path)).expect("resolve_workspace");
assert_eq!(
resolved.canonicalize().unwrap(),
path.canonicalize().unwrap(),
"expected the directory itself, got {resolved:?}"
);
}
#[test]
fn none_path_uses_current_directory() {
// When None is passed the function should not error; it should return
// something (either the cwd or its git root).
let result = resolve_workspace(None);
assert!(result.is_ok(), "resolve_workspace(None) returned error: {result:?}");
let resolved = result.unwrap();
assert!(resolved.is_absolute(), "result should be absolute: {resolved:?}");
}
}