use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ConsoleError {
#[error(
"not an Autumn project: no `Cargo.toml` found in {0}\n\
Run `autumn console` from a project root, or pass --package <name>.\n\
See: docs/guide/console.md"
)]
NotAProject(String),
#[error("could not parse Cargo.toml: {0}")]
ManifestParse(String),
#[error(
"Cargo.toml has no `autumn-web` dependency, so this is not an Autumn \
project\n\
See: docs/guide/console.md"
)]
MissingAutumnWebDependency,
#[error(
"Cargo.toml's `[features]` is not a table, so the `playground` feature \
cannot be defined; fix it by hand and re-run\n\
See: docs/guide/console.md"
)]
UnusableFeaturesTable,
#[error(
"Cargo.toml's `[features] playground` is not an array, so \
`autumn-web/seed` cannot be added to it; rename that feature or make \
it a list and re-run\n\
See: docs/guide/console.md"
)]
UnusablePlaygroundFeature,
#[error(
"no package named `{0}` in this workspace; run `cargo metadata --no-deps` \
to see the available members"
)]
UnknownPackage(String),
#[error(
"`[features] default` enables `playground`, so the playground would be \
built by a plain `cargo build` — the isolation `autumn console` relies \
on. Remove `playground` from the default feature list (directly or via \
`{0}`) and re-run.\n\
See: docs/guide/console.md"
)]
PlaygroundFeatureIsDefault(String),
#[error(
"Cargo.toml already declares `[[bin]] name = \"playground\"` without \
`required-features = [\"playground\"]`, so it would be built by a plain \
`cargo build`. Add this line to that entry and re-run:\n\n \
required-features = [\"playground\"]\n\n\
See: docs/guide/console.md"
)]
PlaygroundBinNotGated,
#[error(
"Cargo.toml's `[[bin]] name = \"playground\"` requires the feature \
`{0}`, which `autumn console` does not enable — it activates the \
default features plus `playground`, and `required-features` is an \
all-of list, so Cargo would refuse to build the target. Either drop \
`{0}` from that entry, or declare `{0}` and have `playground` enable \
it:\n\n [features]\n {0} = []\n playground = \
[\"autumn-web/seed\", \"{0}\"]\n\n\
Note `default` is not implicitly present: a gate naming a feature the \
manifest never declares can never be satisfied.\n\
See: docs/guide/console.md"
)]
PlaygroundBinGateUnsatisfiable(String),
#[error(
"the playground would be written to `{0}`, which is outside this \
package. `autumn console` only ever writes inside the package \
directory, so nothing has been created. This happens when a \
`[[bin]] path` points outside the package, or when a directory on the \
way (e.g. `src/bin`) is a symlink leading out of the checkout.\n\
See: docs/guide/console.md"
)]
PlaygroundPathEscapesPackage(String),
#[error(
"the playground destination is unusable: {0}. Nothing has been \
written — remove or rename it and re-run.\n\
See: docs/guide/console.md"
)]
PlaygroundDestinationUnusable(String),
#[error(
"Cargo.toml has a root-level `bin` key that is not a `[[bin]]` table \
array, so the playground target cannot be registered — and an \
unregistered `src/bin/playground.rs` would be auto-discovered as an \
ungated binary and join every normal build. Replace it with `[[bin]]` \
sections and re-run.\n\
See: docs/guide/console.md"
)]
UnusableBinTable,
#[error(
"this package uses Rust edition 2015, where declaring a target turns off \
Cargo's auto-discovery of the others — so `autumn console` cannot add \
the playground entry without dropping your existing binaries from the \
build. A scaffolded `src/bin/playground.rs` would meanwhile be \
auto-discovered as an ungated binary and join every normal build, so \
nothing has been written. Add this to Cargo.toml yourself, then re-run:\
\n\n [[bin]]\n name = \"playground\"\n path = \
\"src/bin/playground.rs\"\n required-features = [\"playground\"]\n\n\
See: docs/guide/console.md"
)]
CannotIsolateOnEdition2015,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScaffoldOutcome {
Created,
Kept,
Regenerated,
}
impl ScaffoldOutcome {
const fn verb(self) -> &'static str {
match self {
Self::Created => "Created",
Self::Kept => "Kept",
Self::Regenerated => "Regenerated",
}
}
const fn writes_file(self) -> bool {
matches!(self, Self::Created | Self::Regenerated)
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ManifestChanges {
pub added_bin: bool,
pub added_feature: bool,
}
impl ManifestChanges {
const fn any(self) -> bool {
self.added_bin || self.added_feature
}
}
pub const PLAYGROUND_REL_PATH: &str = "src/bin/playground.rs";
pub const PLAYGROUND_BIN_NAME: &str = "playground";
pub const PLAYGROUND_FEATURE: &str = "playground";
const MAX_TEMP_FILE_ATTEMPTS: u32 = 16;
const REQUIRED_FEATURE: &str = "seed";
const APP_MODULES: &[&str] = &["schema", "models", "repositories", "policies"];
const PLAYGROUND_TEMPLATE: &str = include_str!("templates/playground.rs.tmpl");
pub const REPL_FEATURE: &str = "autumn-web/repl";
const REPL_ENV: &str = "AUTUMN_CONSOLE_REPL";
const REPL_ACK_ENV: &str = "AUTUMN_CONSOLE_REPL_ACK";
#[must_use]
pub fn playground_command(
project_dir: &Path,
package: Option<&str>,
profile: &str,
repl_ack: Option<&Path>,
) -> Command {
let mut cmd = Command::new("cargo");
cmd.args(playground_cargo_args(package, repl_ack.is_some()));
cmd.env("AUTUMN_ENV", profile);
cmd.env("AUTUMN_PROFILE", profile);
cmd.current_dir(project_dir);
if let Some(ack) = repl_ack {
cmd.env(REPL_ENV, "1");
cmd.env(REPL_ACK_ENV, ack);
}
cmd
}
pub fn repl_handshake(ack: &Path) -> Result<(), String> {
if ack.exists() {
return Ok(());
}
Err(
"the playground exited without opening the REPL. `--repl` opens it \
from `SeedContext::build()`, and this playground never reached that \
call, so its own code ran instead. Restore the `SeedContext::build()` \
call, or regenerate the playground with `autumn console --force`."
.to_owned(),
)
}
#[must_use]
pub fn playground_cargo_args(package: Option<&str>, repl: bool) -> Vec<String> {
let features = if repl {
format!("{PLAYGROUND_FEATURE},{REPL_FEATURE}")
} else {
PLAYGROUND_FEATURE.to_owned()
};
let mut args: Vec<String> = ["run", "--bin", PLAYGROUND_BIN_NAME, "--features"]
.map(str::to_owned)
.into();
args.push(features);
if let Some(pkg) = package {
args.extend(["--package".to_owned(), pkg.to_owned()]);
}
args
}
pub const fn scaffold_outcome(exists: bool, force: bool) -> ScaffoldOutcome {
match (exists, force) {
(false, _) => ScaffoldOutcome::Created,
(true, false) => ScaffoldOutcome::Kept,
(true, true) => ScaffoldOutcome::Regenerated,
}
}
pub fn app_module_decls(project_root: &Path, playground_rel: &str) -> String {
let src = project_root.join("src");
let playground_dir = lexically_normalize(&project_root.join(playground_rel.replace('\\', "/")));
let playground_dir = playground_dir
.parent()
.unwrap_or(&playground_dir)
.to_path_buf();
let playground_dir = resolve_through_symlinks(&playground_dir);
let mut out = String::new();
for name in APP_MODULES {
let target = if src.join(name).join("mod.rs").is_file() {
src.join(name).join("mod.rs")
} else if src.join(format!("{name}.rs")).is_file() {
src.join(format!("{name}.rs"))
} else {
continue;
};
let rel = relative_path_from(&playground_dir, &resolve_through_symlinks(&target));
let _ = writeln!(out, "#[path = \"{rel}\"]\nmod {name};");
}
out
}
fn resolve_through_symlinks(path: &Path) -> PathBuf {
let normalized = lexically_normalize(path);
if let Ok(real) = normalized.canonicalize() {
return real;
}
let mut suffix: Vec<std::ffi::OsString> = Vec::new();
let mut probe = normalized.as_path();
while let Some(parent) = probe.parent() {
if let Some(name) = probe.file_name() {
suffix.push(name.to_os_string());
}
if let Ok(real) = parent.canonicalize() {
let mut resolved = real;
resolved.extend(suffix.iter().rev());
return resolved;
}
probe = parent;
}
normalized
}
fn lexically_normalize(path: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if matches!(out.components().next_back(), Some(Component::Normal(_))) {
out.pop();
} else {
out.push("..");
}
}
other => out.push(other.as_os_str()),
}
}
out
}
fn relative_path_from(from_dir: &Path, to: &Path) -> String {
let from = lexically_normalize(from_dir);
let to = lexically_normalize(to);
let mut from_parts = from.components().peekable();
let mut to_parts = to.components().peekable();
while from_parts.peek().is_some() && from_parts.peek() == to_parts.peek() {
from_parts.next();
to_parts.next();
}
let mut rel = "../".repeat(from_parts.count());
let tail: PathBuf = to_parts.collect();
rel.push_str(&tail.to_string_lossy().replace('\\', "/"));
rel
}
pub fn render_playground(project_name: &str, app_modules: &str) -> String {
let rendered = PLAYGROUND_TEMPLATE
.replace("\r\n", "\n")
.replace("{{project_name}}", project_name)
.replace("{{app_modules}}", app_modules.trim_end());
collapse_blank_lines(&rendered)
}
fn collapse_blank_lines(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut blank_run = 0usize;
for line in src.lines() {
if line.trim().is_empty() {
blank_run += 1;
if blank_run > 1 {
continue;
}
} else {
blank_run = 0;
}
out.push_str(line);
out.push('\n');
}
out
}
fn package_name(manifest: &str) -> Option<String> {
manifest
.parse::<toml_edit::DocumentMut>()
.ok()?
.get("package")?
.get("name")?
.as_str()
.map(str::to_owned)
}
pub fn ensure_manifest_wiring(
manifest: &str,
inherited_edition: Option<&str>,
) -> Result<(String, ManifestChanges), ConsoleError> {
let mut doc = manifest
.parse::<toml_edit::DocumentMut>()
.map_err(|e| ConsoleError::ManifestParse(e.to_string()))?;
if doc
.get("dependencies")
.and_then(|deps| deps.get("autumn-web"))
.is_none()
{
return Err(ConsoleError::MissingAutumnWebDependency);
}
if let Some(via) = default_feature_chain_to_playground(&doc) {
return Err(ConsoleError::PlaygroundFeatureIsDefault(via));
}
if let Some(entry) = declared_playground_bin(&doc)
&& let Some(problem) = playground_bin_gate_problem(&doc, entry)
{
return Err(problem);
}
if doc
.get("bin")
.is_some_and(|b| b.as_array_of_tables().is_none())
{
return Err(ConsoleError::UnusableBinTable);
}
if declared_playground_bin(&doc).is_none()
&& adding_bin_would_break_autodiscovery(&doc, inherited_edition)
{
return Err(ConsoleError::CannotIsolateOnEdition2015);
}
let changes = ManifestChanges {
added_feature: ensure_playground_feature(&mut doc)?,
added_bin: ensure_playground_bin(&mut doc),
};
Ok((doc.to_string(), changes))
}
fn playground_bin_gate_problem(
doc: &toml_edit::DocumentMut,
entry: &toml_edit::Table,
) -> Option<ConsoleError> {
let required: Vec<String> = entry
.get("required-features")
.and_then(toml_edit::Item::as_array)
.into_iter()
.flatten()
.filter_map(|f| f.as_str())
.map(str::to_owned)
.collect();
if !required.iter().any(|f| f == PLAYGROUND_FEATURE) {
return Some(ConsoleError::PlaygroundBinNotGated);
}
let mut roots = vec![PLAYGROUND_FEATURE];
if declares_feature(doc, "default") {
roots.push("default");
}
let activated = feature_closure(doc, &roots);
required
.into_iter()
.find(|f| f != PLAYGROUND_FEATURE && !activated.contains(f))
.map(ConsoleError::PlaygroundBinGateUnsatisfiable)
}
fn declares_feature(doc: &toml_edit::DocumentMut, name: &str) -> bool {
doc.get("features")
.and_then(toml_edit::Item::as_table_like)
.is_some_and(|features| features.contains_key(name))
}
fn default_feature_chain_to_playground(doc: &toml_edit::DocumentMut) -> Option<String> {
let features = doc
.get("features")
.and_then(toml_edit::Item::as_table_like)?;
let mut queue: Vec<String> = vec!["default".to_owned()];
let mut seen: Vec<String> = Vec::new();
while let Some(name) = queue.pop() {
if seen.contains(&name) {
continue;
}
let enables = enabled_by(doc, features, &name);
seen.push(name.clone());
if enables.iter().any(|e| e == PLAYGROUND_FEATURE) {
return Some(name);
}
queue.extend(enables);
}
None
}
fn feature_closure(doc: &toml_edit::DocumentMut, roots: &[&str]) -> Vec<String> {
let Some(features) = doc.get("features").and_then(toml_edit::Item::as_table_like) else {
return roots.iter().map(|r| (*r).to_owned()).collect();
};
let mut queue: Vec<String> = roots.iter().map(|r| (*r).to_owned()).collect();
let mut seen: Vec<String> = Vec::new();
while let Some(name) = queue.pop() {
if seen.contains(&name) {
continue;
}
queue.extend(enabled_by(doc, features, &name));
seen.push(name);
}
seen
}
fn enabled_by(
doc: &toml_edit::DocumentMut,
features: &dyn toml_edit::TableLike,
name: &str,
) -> Vec<String> {
features
.get(name)
.and_then(toml_edit::Item::as_array)
.into_iter()
.flatten()
.filter_map(|v| v.as_str())
.filter_map(|edge| match edge.split_once('/') {
Some((dep, _)) if dep.ends_with('?') => None,
Some((dep, _)) => is_optional_dependency(doc, dep).then(|| dep.to_owned()),
None if edge.starts_with("dep:") => None,
None => Some(edge.to_owned()),
})
.collect()
}
fn is_optional_dependency(doc: &toml_edit::DocumentMut, name: &str) -> bool {
fn declares_optional(table: Option<&toml_edit::Item>, name: &str) -> bool {
table
.and_then(toml_edit::Item::as_table_like)
.and_then(|deps| deps.get(name))
.and_then(toml_edit::Item::as_table_like)
.is_some_and(|dep| {
dep.get("optional")
.and_then(toml_edit::Item::as_bool)
.unwrap_or(false)
})
}
if declares_optional(doc.get("dependencies"), name)
|| declares_optional(doc.get("build-dependencies"), name)
{
return true;
}
doc.get("target")
.and_then(toml_edit::Item::as_table_like)
.is_some_and(|targets| {
targets.iter().any(|(_, cfg)| {
cfg.as_table_like().is_some_and(|cfg| {
declares_optional(cfg.get("dependencies"), name)
|| declares_optional(cfg.get("build-dependencies"), name)
})
})
})
}
fn ensure_playground_feature(doc: &mut toml_edit::DocumentMut) -> Result<bool, ConsoleError> {
use toml_edit::{Array, Item, Table, Value};
let optional_dep_named_playground = is_optional_dependency(doc, PLAYGROUND_FEATURE);
let features = doc
.entry("features")
.or_insert_with(|| Item::Table(Table::new()));
let Some(features) = features.as_table_like_mut() else {
return Err(ConsoleError::UnusableFeaturesTable);
};
let seed_with_dep = !features.contains_key(PLAYGROUND_FEATURE) && optional_dep_named_playground;
let enables = features.entry(PLAYGROUND_FEATURE).or_insert_with(|| {
let mut initial = Array::new();
if seed_with_dep {
initial.push(format!("dep:{PLAYGROUND_FEATURE}"));
}
Item::Value(Value::Array(initial))
});
let Some(enables) = enables.as_array_mut() else {
return Err(ConsoleError::UnusablePlaygroundFeature);
};
let edge = format!("autumn-web/{REQUIRED_FEATURE}");
if enables.iter().any(|f| f.as_str() == Some(edge.as_str())) {
return Ok(false);
}
enables.push(edge.as_str());
Ok(true)
}
fn ensure_playground_bin(doc: &mut toml_edit::DocumentMut) -> bool {
use toml_edit::{Array, ArrayOfTables, Item, Table, Value, value};
if declared_playground_path(doc).is_some() {
return false;
}
let bins = doc
.entry("bin")
.or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new()));
let Some(bins) = bins.as_array_of_tables_mut() else {
return false;
};
let mut required = Array::new();
required.push(PLAYGROUND_FEATURE);
let mut entry = Table::new();
entry.insert("name", value(PLAYGROUND_BIN_NAME));
entry.insert("path", value(PLAYGROUND_REL_PATH));
entry.insert("required-features", Item::Value(Value::Array(required)));
bins.push(entry);
true
}
pub fn declared_playground_path(doc: &toml_edit::DocumentMut) -> Option<String> {
Some(
declared_playground_bin(doc)?
.get("path")
.and_then(toml_edit::Item::as_str)
.unwrap_or(PLAYGROUND_REL_PATH)
.to_owned(),
)
}
fn declared_playground_bin(doc: &toml_edit::DocumentMut) -> Option<&toml_edit::Table> {
doc.get("bin")
.and_then(toml_edit::Item::as_array_of_tables)?
.iter()
.find(|t| t.get("name").and_then(toml_edit::Item::as_str) == Some(PLAYGROUND_BIN_NAME))
}
pub fn adding_bin_would_break_autodiscovery(
doc: &toml_edit::DocumentMut,
inherited_edition: Option<&str>,
) -> bool {
let Some(package) = doc.get("package").and_then(toml_edit::Item::as_table_like) else {
return false;
};
if !package_is_edition_2015(package, inherited_edition) {
return false;
}
let bin_config_present = doc.get("bin").is_some() || package.get("autobins").is_some();
!bin_config_present
}
fn package_is_edition_2015(
package: &dyn toml_edit::TableLike,
inherited_edition: Option<&str>,
) -> bool {
package.get("edition").is_none_or(|item| {
item.as_str().map_or_else(
|| inherited_edition == Some("2015"),
|edition| edition == "2015",
)
})
}
fn workspace_inherited_edition(project_dir: &Path) -> Option<String> {
for dir in project_dir.ancestors() {
let manifest = dir.join("Cargo.toml");
if !manifest.is_file() {
continue;
}
let Ok(doc) = std::fs::read_to_string(&manifest)
.ok()?
.parse::<toml_edit::DocumentMut>()
else {
continue;
};
if let Some(edition) = doc
.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("edition"))
.and_then(toml_edit::Item::as_str)
{
return Some(edition.to_owned());
}
}
None
}
fn playground_path_escapes_package(project_dir: &Path, playground_rel: &str) -> bool {
let root = lexically_normalize(project_dir);
let intended = lexically_normalize(&project_dir.join(playground_rel.replace('\\', "/")));
if !intended.starts_with(&root) {
return true;
}
let real_root = root.canonicalize().unwrap_or(root);
let mut ancestor = intended.parent();
while let Some(dir) = ancestor {
if let Ok(real) = dir.canonicalize() {
return !real.starts_with(&real_root);
}
ancestor = dir.parent();
}
false
}
fn playground_destination_problem(project_dir: &Path, playground_rel: &str) -> Option<String> {
let root = lexically_normalize(project_dir);
let path = lexically_normalize(&project_dir.join(playground_rel.replace('\\', "/")));
if path.symlink_metadata().is_ok() && !path.is_file() {
return Some(format!(
"`{playground_rel}` exists but is not a regular file"
));
}
let mut ancestor = path.parent();
while let Some(dir) = ancestor {
if dir == root || !dir.starts_with(&root) {
break;
}
if dir.symlink_metadata().is_ok() && !dir.is_dir() {
return Some(format!(
"`{}` exists but is not a directory, so the playground's parent \
directory cannot be created",
relative_path_from(&root, dir)
));
}
ancestor = dir.parent();
}
None
}
fn fail(err: &ConsoleError) -> ! {
eprintln!("\u{2717} {err}");
std::process::exit(1);
}
fn fail_io(path: &Path, err: &std::io::Error) -> ! {
eprintln!("\u{2717} could not write {}: {err}", path.display());
std::process::exit(1);
}
fn write_atomic(path: &Path, contents: &str) -> std::io::Result<()> {
let staged = stage_atomic(path, contents)?;
commit_staged(&staged, path)
}
fn stage_atomic(path: &Path, contents: &str) -> std::io::Result<PathBuf> {
use std::io::Write as _;
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let stem = path
.file_name()
.and_then(std::ffi::OsStr::to_str)
.unwrap_or("autumn-console");
for attempt in 0..MAX_TEMP_FILE_ATTEMPTS {
let tmp = dir.join(format!(
".{stem}.autumn-console-{}-{attempt}.tmp",
std::process::id()
));
let mut file = match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)
{
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
};
let written = file
.write_all(contents.as_bytes())
.and_then(|()| file.sync_all());
drop(file);
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
return Ok(tmp);
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"could not create a temporary file next to {}",
path.display()
),
))
}
fn commit_staged(staged: &Path, path: &Path) -> std::io::Result<()> {
std::fs::rename(staged, path).inspect_err(|_| {
let _ = std::fs::remove_file(staged);
})
}
fn resolve_project_dir(package: Option<&str>) -> PathBuf {
let Some(pkg) = package else {
return std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
};
crate::seed::find_package_dir(pkg)
.unwrap_or_else(|| fail(&ConsoleError::UnknownPackage(pkg.to_owned())))
}
fn load_manifest(manifest_path: &Path, project_dir: &Path) -> (String, toml_edit::DocumentMut) {
if !manifest_path.is_file() {
fail(&ConsoleError::NotAProject(
project_dir.display().to_string(),
));
}
let manifest = std::fs::read_to_string(manifest_path).unwrap_or_else(|e| {
eprintln!("\u{2717} could not read {}: {e}", manifest_path.display());
std::process::exit(1);
});
let doc = manifest
.parse::<toml_edit::DocumentMut>()
.unwrap_or_else(|e| fail(&ConsoleError::ManifestParse(e.to_string())));
(manifest, doc)
}
fn stage_playground(path: &Path, rel: &str, project_dir: &Path, manifest: &str) -> PathBuf {
let project_name = package_name(manifest).unwrap_or_else(|| project_dir.display().to_string());
let source = render_playground(&project_name, &app_module_decls(project_dir, rel));
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
eprintln!("\u{2717} could not create {}: {e}", parent.display());
std::process::exit(1);
}
match stage_atomic(path, &source) {
Ok(staged) => staged,
Err(e) => fail_io(path, &e),
}
}
fn report_manifest_edits(changes: ManifestChanges) {
let mut edits: Vec<String> = Vec::new();
if changes.added_feature {
edits.push(format!(
"[features] {PLAYGROUND_FEATURE} = [\"autumn-web/{REQUIRED_FEATURE}\"]"
));
}
if changes.added_bin {
edits.push(format!(
"[[bin]] {PLAYGROUND_BIN_NAME} (required-features = [\"{PLAYGROUND_FEATURE}\"])"
));
}
eprintln!(" Updated Cargo.toml ({})", edits.join(", "));
}
fn commit_playground(
staged: &Path,
playground_path: &Path,
manifest_path: &Path,
original_manifest: &str,
manifest_was_edited: bool,
) {
let Err(e) = commit_staged(staged, playground_path) else {
return;
};
if manifest_was_edited && let Err(restore) = write_atomic(manifest_path, original_manifest) {
eprintln!(
"\u{2717} could not restore {} after the playground write failed: \
{restore}. Cargo.toml still carries the console's edits; remove \
them by hand, or re-run once {} is writable.",
manifest_path.display(),
playground_path.display()
);
}
fail_io(playground_path, &e);
}
pub fn run(profile: &str, package: Option<&str>, force: bool, scaffold_only: bool, repl: bool) {
eprintln!("\u{1F342} autumn console\n");
eprintln!(" Profile: {profile}");
let project_dir = resolve_project_dir(package);
let manifest_path = project_dir.join("Cargo.toml");
let (manifest, doc) = load_manifest(&manifest_path, &project_dir);
let (updated_manifest, changes) = ensure_manifest_wiring(
&manifest,
workspace_inherited_edition(&project_dir).as_deref(),
)
.unwrap_or_else(|e| fail(&e));
let playground_rel =
declared_playground_path(&doc).unwrap_or_else(|| PLAYGROUND_REL_PATH.to_owned());
if playground_path_escapes_package(&project_dir, &playground_rel) {
fail(&ConsoleError::PlaygroundPathEscapesPackage(playground_rel));
}
let playground_path = project_dir.join(&playground_rel);
let exists = playground_path.is_file();
if exists
&& force
&& std::fs::symlink_metadata(&playground_path).is_ok_and(|m| m.file_type().is_symlink())
{
eprintln!(
"\u{2717} {playground_rel} is a symlink; refusing to --force through \
it. Remove the link first if you want a fresh playground."
);
std::process::exit(1);
}
let outcome = scaffold_outcome(exists, force);
if outcome.writes_file()
&& let Some(problem) = playground_destination_problem(&project_dir, &playground_rel)
{
fail(&ConsoleError::PlaygroundDestinationUnusable(problem));
}
let staged = outcome
.writes_file()
.then(|| stage_playground(&playground_path, &playground_rel, &project_dir, &manifest));
if changes.any() {
if let Err(e) = write_atomic(&manifest_path, &updated_manifest) {
if let Some(staged) = &staged {
let _ = std::fs::remove_file(staged);
}
fail_io(&manifest_path, &e);
}
report_manifest_edits(changes);
}
if let Some(staged) = staged {
commit_playground(
&staged,
&playground_path,
&manifest_path,
&manifest,
changes.any(),
);
eprintln!(" {} {playground_rel}", outcome.verb());
} else {
eprintln!(
" {} {playground_rel} (pass --force to regenerate it from the template)",
outcome.verb()
);
}
if scaffold_only {
let next = if repl {
"autumn console --repl"
} else {
"autumn console"
};
eprintln!("\n\u{2713} Playground ready. Edit {playground_rel}, then run `{next}`.");
return;
}
let code = run_playground(&project_dir, package, profile, repl);
if code != 0 {
std::process::exit(code);
}
}
fn run_playground(project_dir: &Path, package: Option<&str>, profile: &str, repl: bool) -> i32 {
if repl {
eprintln!("\n Building the playground and opening the REPL...\n");
} else {
eprintln!("\n Building and running the playground...\n");
}
let ack_dir = if repl {
match tempfile::TempDir::new() {
Ok(dir) => Some(dir),
Err(e) => {
eprintln!("\u{2717} Failed to create a temporary directory: {e}");
return 1;
}
}
} else {
None
};
let ack = ack_dir.as_ref().map(|dir| dir.path().join("repl-opened"));
let mut cmd = playground_command(project_dir, package, profile, ack.as_deref());
match cmd.status() {
Ok(status) if status.success() => {
if let Some(ack) = &ack
&& let Err(msg) = repl_handshake(ack)
{
eprintln!("\n\u{2717} {msg}");
return 1;
}
eprintln!("\n\u{2713} Playground finished.");
0
}
Ok(status) => {
eprintln!(
"\n\u{2717} Playground exited with a non-zero status ({}).",
status
.code()
.map_or_else(|| "signal".to_owned(), |c| c.to_string())
);
status.code().unwrap_or(1)
}
Err(e) => {
eprintln!("\u{2717} Failed to run cargo: {e}");
1
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn scaffold_outcome_created_when_absent() {
assert_eq!(scaffold_outcome(false, false), ScaffoldOutcome::Created);
}
#[test]
fn scaffold_outcome_created_when_absent_even_with_force() {
assert_eq!(scaffold_outcome(false, true), ScaffoldOutcome::Created);
}
#[test]
fn scaffold_outcome_kept_when_present_without_force() {
assert_eq!(scaffold_outcome(true, false), ScaffoldOutcome::Kept);
}
#[test]
fn scaffold_outcome_regenerated_when_present_with_force() {
assert_eq!(scaffold_outcome(true, true), ScaffoldOutcome::Regenerated);
}
#[test]
fn cargo_args_without_repl_are_unchanged() {
assert_eq!(
playground_cargo_args(None, false),
["run", "--bin", "playground", "--features", "playground"]
);
}
#[test]
fn cargo_args_with_repl_add_only_the_autumn_web_feature() {
assert_eq!(
playground_cargo_args(None, true),
[
"run",
"--bin",
"playground",
"--features",
"playground,autumn-web/repl"
]
);
}
fn env_of(cmd: &Command, key: &str) -> Option<String> {
cmd.get_envs()
.find(|(k, _)| *k == key)
.and_then(|(_, v)| v.map(|v| v.to_string_lossy().into_owned()))
}
#[test]
fn repl_mode_is_chosen_by_the_environment_not_the_source() {
let dir = Path::new("/tmp/app");
let ack = Path::new("/tmp/ack/opened");
let repl = playground_command(dir, None, "dev", Some(ack));
assert_eq!(env_of(&repl, "AUTUMN_CONSOLE_REPL").as_deref(), Some("1"));
assert_eq!(
env_of(&repl, "AUTUMN_CONSOLE_REPL_ACK").as_deref(),
Some(ack.to_string_lossy().as_ref())
);
let plain = playground_command(dir, None, "dev", None);
assert_eq!(env_of(&plain, "AUTUMN_CONSOLE_REPL"), None);
assert_eq!(env_of(&plain, "AUTUMN_CONSOLE_REPL_ACK"), None);
assert_eq!(env_of(&plain, "AUTUMN_ENV").as_deref(), Some("dev"));
assert_eq!(plain.get_current_dir(), Some(dir));
}
#[test]
fn a_repl_run_that_never_opened_the_prompt_is_a_failure() {
let dir = TempDir::new().unwrap();
let ack = dir.path().join("opened");
let err = repl_handshake(&ack).expect_err("no prompt was opened");
assert!(err.contains("SeedContext::build()"), "{err}");
std::fs::write(&ack, b"").unwrap();
assert_eq!(repl_handshake(&ack), Ok(()));
}
#[test]
fn cargo_args_forward_the_package() {
let args = playground_cargo_args(Some("my-app"), true);
assert!(args.ends_with(&["--package".to_owned(), "my-app".to_owned()]));
}
#[test]
fn playground_template_marks_the_your_code_here_region() {
let src = render_playground("my-app", "");
assert!(
src.contains("your code here"),
"template must carry a clearly-marked `your code here` region:\n{src}"
);
}
#[test]
fn playground_template_wires_the_shared_config_and_pool_bootstrap() {
let src = render_playground("my-app", "");
assert!(
src.contains("autumn_web::seed::SeedContext"),
"template must reuse the shared SeedContext bootstrap:\n{src}"
);
assert!(
src.contains("SeedContext::build()"),
"template must build the context (config + DB URL resolution):\n{src}"
);
assert!(
src.contains("ctx.conn()"),
"template must check out a pooled connection so a dead DB fails loudly:\n{src}"
);
}
#[test]
fn playground_template_exits_non_zero_on_config_or_connection_failure() {
let src = render_playground("my-app", "");
assert_eq!(
src.matches("std::process::exit(1)").count(),
2,
"both the config/pool build and the connection checkout must exit \
non-zero rather than succeed silently:\n{src}"
);
assert_eq!(
src.matches("{err}").count(),
2,
"both failure branches must print the underlying error rather than \
a generic panic message:\n{src}"
);
}
#[test]
fn playground_template_substitutes_the_project_name() {
let src = render_playground("my-app", "");
assert!(
src.contains("my-app"),
"template must mention the project it belongs to:\n{src}"
);
}
#[test]
fn playground_template_leaves_no_unsubstituted_placeholders() {
let src = render_playground("my-app", "mod models;");
assert!(
!src.contains("{{"),
"template must have no unsubstituted `{{{{…}}}}` tokens:\n{src}"
);
}
#[test]
fn playground_template_embeds_app_module_declarations() {
let src = render_playground("my-app", "#[path = \"../models/mod.rs\"]\nmod models;");
assert!(
src.contains("#[path = \"../models/mod.rs\"]") && src.contains("mod models;"),
"template must embed the detected app-module declarations:\n{src}"
);
}
#[test]
fn playground_template_mentions_autumn_console() {
let src = render_playground("my-app", "");
assert!(
src.contains("autumn console"),
"template header must tell the reader how it is run:\n{src}"
);
}
fn project_with(files: &[&str]) -> TempDir {
let tmp = TempDir::new().unwrap();
for rel in files {
let path = tmp.path().join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "// test fixture\n").unwrap();
}
tmp
}
#[test]
fn app_module_decls_is_empty_for_a_bare_project() {
let tmp = project_with(&["src/main.rs"]);
assert_eq!(app_module_decls(tmp.path(), PLAYGROUND_REL_PATH).trim(), "");
}
#[test]
fn app_module_decls_detects_schema_and_models_directory() {
let tmp = project_with(&["src/main.rs", "src/schema.rs", "src/models/mod.rs"]);
let decls = app_module_decls(tmp.path(), PLAYGROUND_REL_PATH);
assert!(
decls.contains("#[path = \"../schema.rs\"]") && decls.contains("mod schema;"),
"must wire src/schema.rs into the playground crate:\n{decls}"
);
assert!(
decls.contains("#[path = \"../models/mod.rs\"]") && decls.contains("mod models;"),
"must wire src/models/mod.rs into the playground crate:\n{decls}"
);
}
#[test]
fn app_module_decls_detects_single_file_models() {
let tmp = project_with(&["src/main.rs", "src/models.rs"]);
let decls = app_module_decls(tmp.path(), PLAYGROUND_REL_PATH);
assert!(
decls.contains("#[path = \"../models.rs\"]"),
"must support the single-file src/models.rs layout:\n{decls}"
);
}
#[test]
fn app_module_decls_detects_repositories() {
let tmp = project_with(&["src/main.rs", "src/repositories/mod.rs"]);
let decls = app_module_decls(tmp.path(), PLAYGROUND_REL_PATH);
assert!(
decls.contains("mod repositories;"),
"must wire src/repositories into the playground crate:\n{decls}"
);
}
#[test]
fn app_module_decls_prefers_mod_rs_over_single_file() {
let tmp = project_with(&["src/models.rs", "src/models/mod.rs"]);
let decls = app_module_decls(tmp.path(), PLAYGROUND_REL_PATH);
assert_eq!(
decls.matches("mod models;").count(),
1,
"must declare `models` exactly once:\n{decls}"
);
assert!(
decls.contains("../models/mod.rs"),
"the directory layout must win over the stale single file:\n{decls}"
);
}
const PLAIN_MANIFEST: &str = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
autumn-web = "0.6.0"
"#;
#[test]
fn ensure_manifest_wiring_gates_the_bin_behind_required_features() {
let (out, changes) = ensure_manifest_wiring(PLAIN_MANIFEST, None).unwrap();
assert!(changes.added_bin && changes.added_feature);
assert!(
out.contains("[[bin]]") && out.contains("name = \"playground\""),
"must register the playground bin target:\n{out}"
);
assert!(
out.contains("path = \"src/bin/playground.rs\""),
"bin entry must point at the scaffolded file:\n{out}"
);
assert!(
out.contains("required-features = [\"playground\"]"),
"the bin MUST be feature-gated so a bare `cargo build` skips it:\n{out}"
);
}
#[test]
fn ensure_manifest_wiring_defines_the_playground_feature() {
let (out, changes) = ensure_manifest_wiring(PLAIN_MANIFEST, None).unwrap();
assert!(changes.added_feature);
assert!(
out.contains("playground = [\"autumn-web/seed\"]"),
"the gate feature must turn on the autumn-web feature the \
playground bootstraps through:\n{out}"
);
}
#[test]
fn ensure_manifest_wiring_never_touches_the_autumn_web_dependency() {
let manifest = "[package]\nname = \"my-app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n\
[dependencies]\nautumn-web = \"0.6.0\" # pinned for the demo\n";
let (out, _) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(
out.contains("autumn-web = \"0.6.0\" # pinned for the demo"),
"the dependency line must be byte-identical:\n{out}"
);
assert!(
out.parse::<toml_edit::DocumentMut>().is_ok(),
"the rewritten manifest must still be valid TOML:\n{out}"
);
}
#[test]
fn ensure_manifest_wiring_preserves_existing_features() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[features]
default = ["flash"]
flash = ["autumn-web/flash"]
[dependencies]
autumn-web = "0.6.0"
"#;
let (out, changes) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(changes.added_feature);
assert!(
out.contains("default = [\"flash\"]")
&& out.contains("flash = [\"autumn-web/flash\"]")
&& out.contains("playground = "),
"must add `playground` alongside the project's own features:\n{out}"
);
}
#[test]
fn ensure_manifest_wiring_keeps_a_user_defined_playground_feature() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[features]
playground = ["autumn-web/seed", "autumn-web/redis"]
[dependencies]
autumn-web = "0.6.0"
"#;
let (out, changes) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(!changes.added_feature);
assert!(
out.contains("\"autumn-web/redis\""),
"a user-tuned playground feature must not be overwritten:\n{out}"
);
}
#[test]
fn ensure_manifest_wiring_is_idempotent() {
let (once, _) = ensure_manifest_wiring(PLAIN_MANIFEST, None).unwrap();
let (twice, changes) = ensure_manifest_wiring(&once, None).unwrap();
assert_eq!(
changes,
ManifestChanges::default(),
"a second run must report no changes"
);
assert_eq!(once, twice, "a second run must not alter the manifest");
}
#[test]
fn ensure_manifest_wiring_leaves_other_bin_entries_untouched() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "worker"
path = "src/bin/worker.rs"
[dependencies]
autumn-web = "0.6.0"
"#;
let (out, _) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(
out.contains("name = \"worker\"") && out.contains("name = \"playground\""),
"a user's own bin targets must survive alongside ours:\n{out}"
);
}
#[test]
fn ensure_manifest_wiring_merges_seed_into_an_existing_playground_feature() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[features]
playground = []
[dependencies]
autumn-web = "0.6.0"
"#;
let (out, changes) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(changes.added_feature);
assert!(
out.contains("\"autumn-web/seed\""),
"the seed edge must be merged into an existing playground feature:\n{out}"
);
}
#[test]
fn ensure_manifest_wiring_merge_preserves_a_user_tuned_playground_feature() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[features]
playground = ["autumn-web/redis"]
[dependencies]
autumn-web = "0.6.0"
"#;
let (out, changes) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(changes.added_feature);
assert!(
out.contains("\"autumn-web/redis\"") && out.contains("\"autumn-web/seed\""),
"merging must add the edge without dropping the user's own:\n{out}"
);
}
#[test]
fn ensure_manifest_wiring_errors_on_a_non_array_playground_feature() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[features]
playground = "nonsense"
[dependencies]
autumn-web = "0.6.0"
"#;
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::UnusablePlaygroundFeature)
);
}
#[test]
fn ensure_manifest_wiring_recognises_a_path_less_playground_bin() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "playground"
required-features = ["playground"]
[dependencies]
autumn-web = "0.6.0"
"#;
let (out, changes) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(!changes.added_bin, "the existing target must be recognised");
assert_eq!(
out.matches("name = \"playground\"").count(),
1,
"a duplicate binary name makes the manifest unparsable to cargo:\n{out}"
);
}
#[test]
fn ensure_manifest_wiring_rejects_a_default_enabled_playground_feature() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[features]
default = ["flash", "playground"]
flash = []
[dependencies]
autumn-web = "0.6.0"
"#;
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::PlaygroundFeatureIsDefault(
"default".to_owned()
))
);
}
#[test]
fn ensure_manifest_wiring_rejects_a_transitively_default_playground_feature() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[features]
default = ["bundle"]
bundle = ["playground"]
[dependencies]
autumn-web = "0.6.0"
"#;
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::PlaygroundFeatureIsDefault(
"bundle".to_owned()
)),
"the chain must be followed through intermediate features"
);
}
#[test]
fn ensure_manifest_wiring_default_scan_terminates_on_a_feature_cycle() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[features]
default = ["a"]
a = ["b"]
b = ["a"]
[dependencies]
autumn-web = "0.6.0"
"#;
assert!(ensure_manifest_wiring(manifest, None).is_ok());
}
#[test]
fn ensure_manifest_wiring_rejects_an_ungated_existing_playground_bin() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "playground"
path = "src/bin/playground.rs"
[dependencies]
autumn-web = "0.6.0"
"#;
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::PlaygroundBinNotGated)
);
}
#[test]
fn edition_2015_lib_is_not_proof_that_bin_discovery_is_off() {
let manifest = r#"[package]
name = "legacy-app"
version = "0.1.0"
edition = "2015"
[lib]
name = "legacy_app"
[dependencies]
autumn-web = "0.6.0"
"#;
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::CannotIsolateOnEdition2015),
"a [lib] says nothing about bin auto-discovery"
);
}
#[test]
fn edition_2015_with_an_existing_bin_entry_may_add_ours() {
let manifest = r#"[package]
name = "legacy-app"
version = "0.1.0"
edition = "2015"
[[bin]]
name = "worker"
path = "src/bin/worker.rs"
[dependencies]
autumn-web = "0.6.0"
"#;
let (_, changes) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(
changes.added_bin,
"bin discovery is already off, so nothing more can be lost"
);
}
#[test]
fn an_inline_package_table_is_still_guarded_on_edition_2015() {
let manifest = r#"package = { name = "legacy-app", version = "0.1.0", edition = "2015" }
[dependencies]
autumn-web = "0.6.0"
"#;
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::CannotIsolateOnEdition2015)
);
}
#[test]
fn an_inline_package_table_on_a_modern_edition_is_allowed() {
let manifest = r#"package = { name = "app", version = "0.1.0", edition = "2021" }
[dependencies]
autumn-web = "0.6.0"
"#;
let (_, changes) = ensure_manifest_wiring(manifest, None).expect("accepted");
assert!(changes.added_bin, "a modern edition can take the gated bin");
}
#[test]
fn staging_does_not_publish_the_file_until_it_is_committed() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("playground.rs");
let staged = stage_atomic(&target, "fn main() {}").expect("staged");
assert!(staged.exists(), "the scratch file should exist");
assert!(!target.exists(), "the real path must not exist yet");
assert_ne!(
staged.extension().and_then(std::ffi::OsStr::to_str),
Some("rs"),
"a `*.rs` scratch file would be auto-discovered as a target: {}",
staged.display()
);
commit_staged(&staged, &target).expect("committed");
assert_eq!(std::fs::read_to_string(&target).unwrap(), "fn main() {}");
assert!(!staged.exists(), "the scratch file should be gone");
}
#[test]
fn inherited_edition_is_resolved_before_applying_the_2015_rule() {
let manifest = r#"[package]
name = "member"
version = "0.1.0"
edition.workspace = true
[dependencies]
autumn-web = "0.6.0"
"#;
let (_, changes) = ensure_manifest_wiring(manifest, Some("2024")).unwrap();
assert!(changes.added_bin, "an inherited 2024 edition is not legacy");
assert_eq!(
ensure_manifest_wiring(manifest, Some("2015")),
Err(ConsoleError::CannotIsolateOnEdition2015),
"an inherited 2015 edition still gets the legacy treatment"
);
}
#[test]
fn playground_feature_preserves_an_implicit_optional_dependency() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
autumn-web = "0.6.0"
playground = { version = "0.1", optional = true }
"#;
let (out, _) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(
out.contains("\"dep:playground\""),
"the implicit optional-dependency edge must survive:\n{out}"
);
assert!(
out.contains("\"autumn-web/seed\""),
"and the seed edge must still be added:\n{out}"
);
}
#[test]
fn playground_feature_preserves_a_target_specific_optional_dependency() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
autumn-web = "0.6.0"
[target.'cfg(unix)'.dependencies]
playground = { version = "0.1", optional = true }
"#;
let (out, _) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(
out.contains("\"dep:playground\""),
"a target-specific optional dependency has the same implicit \
feature and must be preserved:\n{out}"
);
}
#[test]
fn ensure_manifest_wiring_rejects_an_unusable_bin_key() {
let manifest = r#"bin = []
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
autumn-web = "0.6.0"
"#;
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::UnusableBinTable)
);
}
#[test]
fn playground_feature_does_not_invent_a_dep_edge_for_a_required_dependency() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
autumn-web = "0.6.0"
playground = "0.1"
"#;
let (out, _) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(
!out.contains("dep:playground"),
"a required dependency has no implicit feature to preserve:\n{out}"
);
}
#[test]
fn playground_feature_preserves_an_inline_table_target_dependency() {
let manifest = r#"target = { 'cfg(unix)' = { dependencies = { playground = { version = "0.1", optional = true } } } }
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
autumn-web = "0.6.0"
"#;
let (out, _) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(
out.contains("\"dep:playground\""),
"an inline-table target dependency must be seen too:\n{out}"
);
}
#[test]
fn playground_feature_preserves_an_inline_top_level_dependency_table() {
let manifest = r#"dependencies = { autumn-web = "0.6.0", playground = { version = "0.1", optional = true } }
[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
"#;
let (out, _) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(
out.contains("\"dep:playground\""),
"an inline `dependencies` table must be seen too:\n{out}"
);
}
#[cfg(unix)]
#[test]
fn app_module_decls_resolve_a_symlinked_playground_directory() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join("tools/bin")).unwrap();
std::fs::write(root.join("src/schema.rs"), "// schema\n").unwrap();
std::os::unix::fs::symlink("tools/bin", root.join("custom")).unwrap();
let decls = app_module_decls(root, "custom/playground.rs");
let emitted = decls
.lines()
.find_map(|l| l.strip_prefix("#[path = \"")?.strip_suffix("\"]"))
.expect("a schema declaration");
assert!(
root.join("custom").join(emitted).exists(),
"the emitted path must resolve to a real file when joined onto the \
playground's directory as rustc sees it; got {emitted}"
);
}
#[test]
fn ensure_manifest_wiring_rejects_a_strong_dependency_feature_edge_to_playground() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
autumn-web = "0.6.0"
playground = { version = "0.1", optional = true }
[features]
default = ["playground/std"]
"#;
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::PlaygroundFeatureIsDefault(
"default".to_owned()
))
);
}
#[test]
fn strong_edge_to_a_required_dependency_does_not_activate_a_local_feature() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
autumn-web = "0.6.0"
playground = { version = "0.1" }
[features]
default = ["playground/std"]
playground = []
"#;
let (updated, changes) = ensure_manifest_wiring(manifest, None).expect("accepted");
assert!(changes.added_bin, "the gated bin target should be added");
assert!(updated.contains("required-features = [\"playground\"]"));
}
#[test]
fn ensure_manifest_wiring_allows_a_weak_dependency_feature_edge() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
autumn-web = "0.6.0"
playground = { version = "0.1", optional = true }
[features]
default = ["playground?/std"]
"#;
assert!(
ensure_manifest_wiring(manifest, None).is_ok(),
"a weak edge must not be read as activating the gate"
);
}
#[test]
fn ensure_manifest_wiring_ignores_an_unrelated_strong_edge() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[dependencies]
autumn-web = "0.6.0"
[features]
default = ["autumn-web/mail"]
"#;
assert!(ensure_manifest_wiring(manifest, None).is_ok());
}
#[test]
fn an_inline_root_features_table_is_usable() {
let manifest = r#"features = { playground = [] }
[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
[dependencies]
autumn-web = "0.6.0"
"#;
let (updated, changes) = ensure_manifest_wiring(manifest, None).expect("accepted");
assert!(changes.added_feature, "the seed edge should be merged in");
assert!(
updated.contains("autumn-web/seed"),
"inline table should be edited in place: {updated}"
);
}
#[test]
fn an_inline_root_features_table_is_still_scanned_for_a_default_gate() {
let manifest = r#"features = { default = ["playground"], playground = [] }
[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
[dependencies]
autumn-web = "0.6.0"
"#;
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::PlaygroundFeatureIsDefault(
"default".to_owned()
))
);
}
#[test]
fn a_gate_naming_an_undeclared_default_feature_is_rejected() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
[dependencies]
autumn-web = "0.6.0"
[features]
playground = []
[[bin]]
name = "playground"
path = "src/bin/playground.rs"
required-features = ["playground", "default"]
"#;
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::PlaygroundBinGateUnsatisfiable(
"default".to_owned()
))
);
}
#[test]
fn a_gate_naming_a_declared_default_feature_is_accepted() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2021"
[dependencies]
autumn-web = "0.6.0"
[features]
default = []
playground = []
[[bin]]
name = "playground"
path = "src/bin/playground.rs"
required-features = ["playground", "default"]
"#;
assert!(
ensure_manifest_wiring(manifest, None).is_ok(),
"a declared `default` is activated by the runner and must be accepted"
);
}
#[test]
fn playground_path_inside_the_package_is_accepted() {
let tmp = project_with(&["src/main.rs"]);
assert!(!playground_path_escapes_package(
tmp.path(),
PLAYGROUND_REL_PATH
));
assert!(!playground_path_escapes_package(
tmp.path(),
"custom/playground.rs"
));
}
#[test]
fn a_usable_destination_reports_no_problem() {
let tmp = project_with(&["src/main.rs"]);
assert_eq!(
playground_destination_problem(tmp.path(), PLAYGROUND_REL_PATH),
None
);
std::fs::create_dir_all(tmp.path().join("src/bin")).unwrap();
std::fs::write(tmp.path().join(PLAYGROUND_REL_PATH), "fn main() {}").unwrap();
assert_eq!(
playground_destination_problem(tmp.path(), PLAYGROUND_REL_PATH),
None
);
}
#[test]
fn a_directory_at_the_playground_path_is_reported() {
let tmp = project_with(&["src/main.rs"]);
std::fs::create_dir_all(tmp.path().join(PLAYGROUND_REL_PATH)).unwrap();
let problem = playground_destination_problem(tmp.path(), PLAYGROUND_REL_PATH)
.expect("a directory at the destination is a problem");
assert!(problem.contains("not a regular file"), "{problem}");
}
#[test]
fn a_file_where_the_parent_directory_belongs_is_reported() {
let tmp = project_with(&["src/main.rs"]);
std::fs::write(tmp.path().join("src/bin"), "not a directory").unwrap();
let problem = playground_destination_problem(tmp.path(), PLAYGROUND_REL_PATH)
.expect("a file at src/bin is a problem");
assert!(problem.contains("not a directory"), "{problem}");
assert!(problem.contains("src/bin"), "{problem}");
}
#[test]
fn playground_path_climbing_out_of_the_package_is_rejected() {
let tmp = project_with(&["src/main.rs"]);
assert!(playground_path_escapes_package(
tmp.path(),
"../tools/playground.rs"
));
assert!(playground_path_escapes_package(
tmp.path(),
"src/../../elsewhere/playground.rs"
));
}
#[cfg(unix)]
#[test]
fn playground_path_through_a_symlinked_ancestor_is_rejected() {
let tmp = TempDir::new().unwrap();
let project = tmp.path().join("app");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(project.join("src")).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::os::unix::fs::symlink(&outside, project.join("src/bin")).unwrap();
assert!(
playground_path_escapes_package(&project, PLAYGROUND_REL_PATH),
"a symlinked ancestor directory must be treated as an escape"
);
}
#[test]
fn write_atomic_does_not_write_through_a_symlinked_scratch_file() {
let tmp = TempDir::new().unwrap();
let victim = tmp.path().join("victim.txt");
std::fs::write(&victim, "SECRET").unwrap();
let target = tmp.path().join("Cargo.toml");
std::fs::write(&target, "original").unwrap();
for attempt in 0..MAX_TEMP_FILE_ATTEMPTS {
let decoy = tmp.path().join(format!(
".Cargo.toml.autumn-console-{}-{attempt}.tmp",
std::process::id()
));
#[cfg(unix)]
std::os::unix::fs::symlink(&victim, &decoy).unwrap();
#[cfg(not(unix))]
std::fs::write(&decoy, "decoy").unwrap();
}
assert!(
write_atomic(&target, "new contents").is_err(),
"every candidate name is taken, so the write must fail rather than \
follow a link"
);
assert_eq!(
std::fs::read_to_string(&victim).unwrap(),
"SECRET",
"the symlink target must never be written through"
);
}
#[test]
fn write_atomic_replaces_the_target_and_cleans_up() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("Cargo.toml");
std::fs::write(&target, "old").unwrap();
write_atomic(&target, "new").unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "new");
let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains("autumn-console"))
.collect();
assert!(
leftovers.is_empty(),
"temp files must not linger: {leftovers:?}"
);
}
#[test]
fn ensure_manifest_wiring_rejects_a_partially_satisfiable_gate() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[features]
playground = ["autumn-web/seed"]
tools = []
[[bin]]
name = "playground"
required-features = ["playground", "tools"]
[dependencies]
autumn-web = "0.6.0"
"#;
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::PlaygroundBinGateUnsatisfiable(
"tools".to_owned()
))
);
}
#[test]
fn ensure_manifest_wiring_accepts_a_gate_whose_extras_playground_enables() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[features]
playground = ["autumn-web/seed", "tools"]
tools = []
[[bin]]
name = "playground"
required-features = ["playground", "tools"]
[dependencies]
autumn-web = "0.6.0"
"#;
assert!(ensure_manifest_wiring(manifest, None).is_ok());
}
#[test]
fn ensure_manifest_wiring_accepts_a_gate_extra_that_is_a_default_feature() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[features]
default = ["flash"]
flash = []
playground = ["autumn-web/seed"]
[[bin]]
name = "playground"
required-features = ["playground", "flash"]
[dependencies]
autumn-web = "0.6.0"
"#;
assert!(ensure_manifest_wiring(manifest, None).is_ok());
}
#[test]
fn ensure_manifest_wiring_rejects_a_foreign_gate_on_the_playground_bin() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "playground"
required-features = ["tools"]
[dependencies]
autumn-web = "0.6.0"
"#;
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::PlaygroundBinNotGated)
);
}
#[test]
fn isolation_guards_leave_the_manifest_untouched() {
for manifest in [
"[package]\nname = \"a\"\nedition = \"2024\"\n\n[features]\ndefault = [\"playground\"]\n\n[dependencies]\nautumn-web = \"0.6.0\"\n",
"[package]\nname = \"a\"\nedition = \"2024\"\n\n[[bin]]\nname = \"playground\"\n\n[dependencies]\nautumn-web = \"0.6.0\"\n",
] {
assert!(
ensure_manifest_wiring(manifest, None).is_err(),
"expected a refusal for:\n{manifest}"
);
}
}
#[test]
fn app_module_decls_rebase_for_a_relocated_playground() {
let tmp = project_with(&["src/main.rs", "src/schema.rs"]);
let decls = app_module_decls(tmp.path(), "custom/playground.rs");
assert!(
decls.contains("#[path = \"../src/schema.rs\"]"),
"a playground one level deep must reach src/ with a single hop:\n{decls}"
);
}
#[test]
fn app_module_decls_rebase_for_a_deeply_nested_playground() {
let tmp = project_with(&["src/main.rs", "src/schema.rs"]);
let decls = app_module_decls(tmp.path(), "tools/dev/pg.rs");
assert!(
decls.contains("#[path = \"../../src/schema.rs\"]"),
"two directories deep needs two hops back to the package root:\n{decls}"
);
}
#[test]
fn app_module_decls_normalize_a_dot_prefixed_playground_path() {
let tmp = project_with(&["src/main.rs", "src/schema.rs"]);
assert_eq!(
app_module_decls(tmp.path(), "./custom/playground.rs"),
app_module_decls(tmp.path(), "custom/playground.rs"),
"a `./` prefix must not change the rebased path"
);
}
#[test]
fn app_module_decls_handle_a_playground_outside_the_package() {
let tmp = project_with(&["src/main.rs", "src/schema.rs"]);
let decls = app_module_decls(tmp.path(), "../tools/playground.rs");
let leaf = tmp
.path()
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
assert!(
decls.contains(&format!("../{leaf}/src/schema.rs")),
"must descend back into the package by name:\n{decls}"
);
}
#[test]
fn relative_path_from_resolves_dot_and_parent_segments() {
assert_eq!(
relative_path_from(Path::new("/a/b/src/bin"), Path::new("/a/b/src/schema.rs")),
"../schema.rs"
);
assert_eq!(
relative_path_from(Path::new("/a/b/./custom"), Path::new("/a/b/src/schema.rs")),
"../src/schema.rs"
);
assert_eq!(
relative_path_from(
Path::new("/a/b/x/../custom"),
Path::new("/a/b/src/schema.rs")
),
"../src/schema.rs"
);
}
#[test]
fn lexically_normalize_collapses_without_touching_the_filesystem() {
assert_eq!(lexically_normalize(Path::new("a/./b")), Path::new("a/b"));
assert_eq!(lexically_normalize(Path::new("a/b/../c")), Path::new("a/c"));
assert_eq!(lexically_normalize(Path::new("../a")), Path::new("../a"));
}
#[test]
fn declared_playground_path_infers_the_default_for_a_path_less_entry() {
let doc = r#"[[bin]]
name = "playground"
"#
.parse::<toml_edit::DocumentMut>()
.unwrap();
assert_eq!(
declared_playground_path(&doc).as_deref(),
Some(PLAYGROUND_REL_PATH),
"a path-less entry resolves to the location Cargo infers"
);
}
#[test]
fn ensure_manifest_wiring_keeps_an_existing_playground_bin_entry() {
let manifest = r#"[package]
name = "my-app"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "playground"
path = "custom/playground.rs"
required-features = ["playground"]
[dependencies]
autumn-web = "0.6.0"
"#;
let (out, changes) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(!changes.added_bin);
assert!(
out.contains("path = \"custom/playground.rs\"")
&& !out.contains("path = \"src/bin/playground.rs\""),
"must not rewrite — or shadow — a user-owned bin entry:\n{out}"
);
}
#[test]
fn declared_playground_path_finds_a_relocated_playground() {
let manifest = r#"[package]
name = "my-app"
[[bin]]
name = "playground"
path = "custom/playground.rs"
"#;
let doc = manifest.parse::<toml_edit::DocumentMut>().unwrap();
assert_eq!(
declared_playground_path(&doc).as_deref(),
Some("custom/playground.rs")
);
}
#[test]
fn declared_playground_path_is_none_without_an_entry() {
let doc = PLAIN_MANIFEST.parse::<toml_edit::DocumentMut>().unwrap();
assert_eq!(declared_playground_path(&doc), None);
}
#[test]
fn edition_2015_without_manual_targets_refuses_the_bin_entry() {
let manifest = r#"[package]
name = "legacy-app"
version = "0.1.0"
[dependencies]
autumn-web = "0.6.0"
"#;
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::CannotIsolateOnEdition2015)
);
}
#[test]
fn edition_2015_with_manual_targets_accepts_the_bin_entry() {
let manifest = r#"[package]
name = "legacy-app"
version = "0.1.0"
[[bin]]
name = "legacy-app"
path = "src/main.rs"
[dependencies]
autumn-web = "0.6.0"
"#;
let (_, changes) = ensure_manifest_wiring(manifest, None).unwrap();
assert!(changes.added_bin);
}
#[test]
fn modern_editions_always_accept_the_bin_entry() {
for edition in ["2018", "2021", "2024"] {
let manifest = format!(
"[package]\nname = \"a\"\nversion = \"0.1.0\"\nedition = \"{edition}\"\n\n\
[dependencies]\nautumn-web = \"0.6.0\"\n"
);
let (_, changes) = ensure_manifest_wiring(&manifest, None).unwrap();
assert!(changes.added_bin, "edition {edition} keeps autobins on");
}
}
#[test]
fn ensure_manifest_wiring_errors_without_an_autumn_web_dependency() {
let manifest = "[package]\nname = \"my-app\"\n\n[dependencies]\nserde = \"1\"\n";
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::MissingAutumnWebDependency)
);
}
#[test]
fn ensure_manifest_wiring_errors_on_an_unparsable_manifest() {
let err = ensure_manifest_wiring("this is not = = toml", None).unwrap_err();
assert!(matches!(err, ConsoleError::ManifestParse(_)));
}
#[test]
fn ensure_manifest_wiring_errors_on_an_unusable_features_key() {
let manifest = "features = \"nope\"\n\n[package]\nname = \"my-app\"\n\
edition = \"2024\"\n\n[dependencies]\nautumn-web = \"0.6.0\"\n";
assert_eq!(
ensure_manifest_wiring(manifest, None),
Err(ConsoleError::UnusableFeaturesTable)
);
}
#[test]
fn not_a_project_error_names_cargo_toml_and_the_directory() {
let msg = ConsoleError::NotAProject("/tmp/x".into()).to_string();
assert!(
msg.contains("Cargo.toml") && msg.contains("/tmp/x"),
"{msg}"
);
}
#[test]
fn missing_dependency_error_mentions_autumn_web() {
let msg = ConsoleError::MissingAutumnWebDependency.to_string();
assert!(msg.contains("autumn-web"), "{msg}");
}
}