use std::fs;
use std::io;
use std::path::{Path, PathBuf};
pub use umbral_casing::{pascal_case_from_ident, to_snake_case};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
Root,
Plugin(String),
}
impl Target {
pub fn parse(s: &str) -> Self {
if s.eq_ignore_ascii_case("root") {
Self::Root
} else {
Self::Plugin(s.to_string())
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedTarget {
pub crate_root: PathBuf,
pub owner_file: PathBuf,
pub is_root: bool,
}
impl ResolvedTarget {
pub fn module_decl(&self, module: &str) -> String {
if self.is_root {
format!("mod {module};")
} else {
format!("pub mod {module};")
}
}
}
#[derive(Debug)]
pub enum CodegenError {
InvalidName(String),
AlreadyExists(PathBuf),
NoSuchPlugin {
asked: String,
available: Vec<String>,
},
NotAProject(PathBuf),
Io(io::Error),
}
impl std::fmt::Display for CodegenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidName(s) if RUST_KEYWORDS.contains(&s.replace('-', "_").as_str()) => {
write!(
f,
"`{s}` is a Rust keyword, so it cannot be a module name — the generated \
`mod {s};` would not parse. Pick another name."
)
}
Self::InvalidName(s) => write!(
f,
"invalid name `{s}`: must be ASCII alphanumeric, underscore or hyphen, \
and must not start with a digit"
),
Self::AlreadyExists(p) => write!(
f,
"`{}` already exists — pick another name, or delete it first. \
Nothing was written.",
p.display()
),
Self::NoSuchPlugin { asked, available } => {
if available.is_empty() {
write!(
f,
"no plugin named `{asked}` — this project has no plugins yet. \
Create one with `umbral startapp <name>`, or use `--in root`."
)
} else {
write!(
f,
"no plugin named `{asked}`. Available: root, {}.",
available.join(", ")
)
}
}
Self::NotAProject(p) => write!(
f,
"`{}` doesn't look like an umbral project — no `src/main.rs`.",
p.display()
),
Self::Io(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for CodegenError {}
impl From<io::Error> for CodegenError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
#[derive(Debug, Clone, Default)]
pub struct Scaffolded {
pub root: PathBuf,
pub files: Vec<PathBuf>,
pub next_steps: Vec<String>,
}
const RUST_KEYWORDS: &[&str] = &[
"as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern",
"false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub",
"ref", "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", "use",
"where", "while", "abstract", "become", "box", "do", "final", "macro", "override", "priv",
"try", "typeof", "unsized", "virtual", "yield",
];
pub fn validate_ident(name: &str) -> Result<(), CodegenError> {
if name.is_empty() {
return Err(CodegenError::InvalidName(String::new()));
}
if name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return Err(CodegenError::InvalidName(name.to_string()));
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(CodegenError::InvalidName(name.to_string()));
}
if RUST_KEYWORDS.contains(&name.replace('-', "_").as_str()) {
return Err(CodegenError::InvalidName(name.to_string()));
}
Ok(())
}
pub fn discover_plugins(project_root: &Path) -> Vec<String> {
let mut names = Vec::new();
let Ok(entries) = fs::read_dir(project_root.join("plugins")) else {
return names;
};
for entry in entries.flatten() {
if !entry.path().join("Cargo.toml").is_file() {
continue;
}
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
}
names.sort();
names
}
pub fn resolve_target(
project_root: &Path,
target: &Target,
) -> Result<ResolvedTarget, CodegenError> {
match target {
Target::Root => {
let owner_file = project_root.join("src/main.rs");
if !owner_file.is_file() {
return Err(CodegenError::NotAProject(project_root.to_path_buf()));
}
Ok(ResolvedTarget {
crate_root: project_root.to_path_buf(),
owner_file,
is_root: true,
})
}
Target::Plugin(name) => {
validate_ident(name)?;
let crate_root = project_root.join("plugins").join(name);
let owner_file = crate_root.join("src/lib.rs");
if !owner_file.is_file() {
return Err(CodegenError::NoSuchPlugin {
asked: name.clone(),
available: discover_plugins(project_root),
});
}
Ok(ResolvedTarget {
crate_root,
owner_file,
is_root: false,
})
}
}
}
pub fn write_new_file(
crate_root: &Path,
rel_path: &str,
contents: &str,
files: &mut Vec<PathBuf>,
) -> Result<(), CodegenError> {
let full = crate_root.join(rel_path);
if full.exists() {
return Err(CodegenError::AlreadyExists(full));
}
if let Some(parent) = full.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&full, contents)?;
files.push(PathBuf::from(rel_path));
Ok(())
}
pub fn declare_module(text: &str, decl: &str) -> Option<String> {
if text.lines().any(|l| l.trim() == decl) {
return None;
}
let idx = text
.lines()
.position(|l| (l.starts_with("mod ") || l.starts_with("pub mod ")) && l.ends_with(';'))?;
Some(insert_line_before(text, idx, decl))
}
pub fn insert_before_marker_once(text: &str, marker: &str, line: &str) -> Option<String> {
if text.lines().any(|l| l.trim() == line.trim()) {
return Some(text.to_string());
}
insert_before_marker(text, marker, line)
}
pub fn insert_before_marker(text: &str, marker: &str, line: &str) -> Option<String> {
let idx = text.lines().position(|l| l.trim() == marker)?;
Some(insert_line_before(text, idx, line))
}
pub fn ensure_dependency(cargo_toml: &Path, name: &str, spec: &str) -> Result<bool, CodegenError> {
let text = fs::read_to_string(cargo_toml)?;
let key = format!("{name} =");
let table_header = format!("[dependencies.{name}]");
let mut in_dependencies = false;
let mut deps_header_idx: Option<usize> = None;
for (idx, line) in text.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
if trimmed == table_header {
return Ok(false);
}
in_dependencies = trimmed == "[dependencies]";
if in_dependencies {
deps_header_idx = Some(idx);
}
continue;
}
if in_dependencies && trimmed.starts_with(&key) {
return Ok(false);
}
}
let Some(idx) = deps_header_idx else {
return Err(CodegenError::Io(io::Error::new(
io::ErrorKind::InvalidData,
format!("`{}` has no [dependencies] section", cargo_toml.display()),
)));
};
let out = insert_line_after(&text, idx, &format!("{name} = {spec}"));
fs::write(cargo_toml, out)?;
Ok(true)
}
struct LineStyle {
ending: &'static str,
trailing_newline: bool,
}
impl LineStyle {
fn of(text: &str) -> Self {
let ending = match text.find('\n') {
Some(i) if i > 0 && text.as_bytes()[i - 1] == b'\r' => "\r\n",
_ => "\n",
};
Self {
ending,
trailing_newline: text.is_empty() || text.ends_with('\n'),
}
}
fn join(&self, lines: &[&str]) -> String {
let mut out = String::new();
for (i, l) in lines.iter().enumerate() {
out.push_str(l);
let last = i + 1 == lines.len();
if !last || self.trailing_newline {
out.push_str(self.ending);
}
}
out
}
}
pub fn insert_line_before(text: &str, idx: usize, line: &str) -> String {
let style = LineStyle::of(text);
let mut lines: Vec<&str> = text.lines().collect();
let idx = idx.min(lines.len());
for (offset, inserted) in line.lines().enumerate() {
lines.insert(idx + offset, inserted);
}
style.join(&lines)
}
pub fn insert_line_after(text: &str, idx: usize, line: &str) -> String {
insert_line_before(text, idx + 1, line)
}
pub mod prompt {
use std::io::{self, BufRead, IsTerminal, Write};
use std::path::Path;
use super::{Target, discover_plugins};
pub fn is_interactive() -> bool {
io::stdin().is_terminal()
}
pub fn ask(question: &str) -> io::Result<String> {
print!("{question}");
io::stdout().flush()?;
let mut line = String::new();
if io::stdin().lock().read_line(&mut line)? == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "cancelled"));
}
Ok(line.trim().to_string())
}
pub fn ask_required(question: &str) -> io::Result<String> {
loop {
let answer = ask(question)?;
if !answer.is_empty() {
return Ok(answer);
}
}
}
pub fn ask_target(project_root: &Path) -> io::Result<Target> {
let plugins = discover_plugins(project_root);
println!();
println!("Where should it live?");
println!(" 1. root — this project's own crate");
for (i, p) in plugins.iter().enumerate() {
println!(" {}. {p} — the `{p}` plugin (travels with it)", i + 2);
}
if plugins.is_empty() {
println!(" (no plugins yet — `umbral startapp <name>` creates one)");
}
println!();
loop {
let answer = ask("Choose [1]: ")?;
if answer.is_empty() {
return Ok(Target::Root);
}
if let Ok(n) = answer.parse::<usize>() {
if n == 1 {
return Ok(Target::Root);
}
if let Some(p) = plugins.get(n - 2) {
return Ok(Target::Plugin(p.clone()));
}
println!(" no such choice: {n}");
continue;
}
if answer.eq_ignore_ascii_case("root") || plugins.iter().any(|p| p == &answer) {
return Ok(Target::parse(&answer));
}
println!(" no such target: `{answer}`");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ensure_dependency_adds_once_and_never_twice() {
let tmp = tempfile::tempdir().expect("tempdir");
let manifest = tmp.path().join("Cargo.toml");
fs::write(
&manifest,
"[package]\nname = \"blog\"\n\n[dependencies]\numbral = \"1\"\n",
)
.unwrap();
assert!(ensure_dependency(&manifest, "umbral-rest", "\"0.0.9\"").unwrap());
let text = fs::read_to_string(&manifest).unwrap();
assert!(text.contains("umbral-rest = \"0.0.9\""), "{text}");
assert!(text.contains("umbral = \"1\""), "{text}");
assert!(!ensure_dependency(&manifest, "umbral-rest", "\"0.0.9\"").unwrap());
assert_eq!(
fs::read_to_string(&manifest)
.unwrap()
.matches("umbral-rest =")
.count(),
1
);
}
#[test]
fn ensure_dependency_refuses_a_manifest_with_no_dependencies_section() {
let tmp = tempfile::tempdir().expect("tempdir");
let manifest = tmp.path().join("Cargo.toml");
fs::write(&manifest, "[package]\nname = \"blog\"\n").unwrap();
assert!(ensure_dependency(&manifest, "umbral-rest", "\"0.0.9\"").is_err());
}
#[test]
fn validate_ident_matches_rust_identifier_rules() {
assert!(validate_ident("is_owner").is_ok());
assert!(validate_ident("IsOwner").is_ok());
assert!(validate_ident("cursor-pagination").is_ok());
assert!(validate_ident("").is_err());
assert!(validate_ident("2fast").is_err());
assert!(validate_ident("is owner").is_err());
}
#[test]
fn casing_round_trips_from_either_input_form() {
for input in ["IsOwner", "is_owner", "is-owner"] {
let pascal = pascal_case_from_ident(input);
assert_eq!(pascal, "IsOwner", "from {input}");
assert_eq!(to_snake_case(&pascal), "is_owner", "from {input}");
}
}
#[test]
fn declare_module_inserts_before_the_first_existing_decl() {
let text = "//! doc\n\nmod seed;\nmod views;\n\nuse foo;\n";
let out = declare_module(text, "mod commands;").expect("should insert");
assert!(
out.contains("mod commands;\nmod seed;\nmod views;"),
"{out}"
);
}
#[test]
fn declare_module_is_idempotent() {
let text = "mod commands;\nmod seed;\n";
assert!(
declare_module(text, "mod commands;").is_none(),
"a declaration already present must not be added twice"
);
}
#[test]
fn declare_module_declines_when_there_is_no_module_list() {
let text = "//! just docs\n\nuse foo;\n";
assert!(declare_module(text, "mod commands;").is_none());
}
#[test]
fn insert_before_marker_places_the_line_above_the_marker() {
let text = "pub mod a;\n// MARK\n";
let out = insert_before_marker(text, "// MARK", "pub mod b;").expect("marker present");
assert_eq!(out, "pub mod a;\npub mod b;\n// MARK\n");
}
#[test]
fn insert_before_marker_declines_when_the_marker_is_gone() {
let text = "pub mod a;\n";
assert!(
insert_before_marker(text, "// MARK", "pub mod b;").is_none(),
"without its marker a generator must decline, not guess"
);
}
#[test]
fn write_new_file_never_overwrites() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut files = Vec::new();
write_new_file(tmp.path(), "src/x.rs", "one", &mut files).expect("first write");
let err = write_new_file(tmp.path(), "src/x.rs", "two", &mut files)
.expect_err("second write must refuse");
assert!(matches!(err, CodegenError::AlreadyExists(_)));
assert_eq!(
fs::read_to_string(tmp.path().join("src/x.rs")).unwrap(),
"one",
"the existing file was clobbered"
);
}
#[test]
fn resolve_target_refuses_a_plugin_name_that_escapes_the_project() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("project");
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
let outside = tmp.path().join("other");
fs::create_dir_all(outside.join("src")).unwrap();
fs::write(outside.join("Cargo.toml"), "[package]\n").unwrap();
fs::write(outside.join("src/lib.rs"), "// someone else's code\n").unwrap();
for escape in [
outside.display().to_string(), "../other".to_string(), "..".to_string(),
"foo/bar".to_string(),
"foo\\bar".to_string(),
] {
let err = resolve_target(&root, &Target::Plugin(escape.clone()))
.expect_err(&format!("`--in {escape}` must not resolve"));
assert!(
matches!(err, CodegenError::InvalidName(_)),
"`--in {escape}` gave {err:?}, expected InvalidName"
);
}
assert_eq!(
fs::read_to_string(outside.join("src/lib.rs")).unwrap(),
"// someone else's code\n"
);
}
#[test]
fn validate_ident_rejects_rust_keywords() {
for kw in ["move", "type", "match", "struct", "self", "impl"] {
assert!(
matches!(validate_ident(kw), Err(CodegenError::InvalidName(_))),
"`{kw}` is a Rust keyword and cannot be a module name"
);
}
assert!(validate_ident("move_rows").is_ok());
assert!(validate_ident("typegen").is_ok());
}
#[test]
fn ensure_dependency_is_section_aware() {
let tmp = tempfile::tempdir().expect("tempdir");
let manifest = tmp.path().join("Cargo.toml");
fs::write(
&manifest,
"[package]\nname = \"blog\"\n\n[dependencies]\numbral = \"1\"\n\n\
[dev-dependencies]\numbral-rest = \"0.0.9\"\n",
)
.unwrap();
assert!(
ensure_dependency(&manifest, "umbral-rest", "\"0.0.10\"").unwrap(),
"a dev-dependency must not count as a dependency"
);
let text = fs::read_to_string(&manifest).unwrap();
let deps_at = text.find("[dependencies]").unwrap();
let dev_at = text.find("[dev-dependencies]").unwrap();
let added_at = text.find("umbral-rest = \"0.0.10\"").unwrap();
assert!(
deps_at < added_at && added_at < dev_at,
"the dep landed outside [dependencies]:\n{text}"
);
}
#[test]
fn ensure_dependency_recognises_the_table_form() {
let tmp = tempfile::tempdir().expect("tempdir");
let manifest = tmp.path().join("Cargo.toml");
let original = "[package]\nname = \"blog\"\n\n[dependencies]\numbral = \"1\"\n\n\
[dependencies.umbral-rest]\nversion = \"0.0.9\"\nfeatures = [\"x\"]\n";
fs::write(&manifest, original).unwrap();
assert!(
!ensure_dependency(&manifest, "umbral-rest", "\"0.0.10\"").unwrap(),
"the table form must read as already-present"
);
assert_eq!(
fs::read_to_string(&manifest).unwrap(),
original,
"a duplicate key was written; cargo would refuse this manifest"
);
}
#[test]
fn edits_preserve_crlf_and_a_missing_trailing_newline() {
let crlf = "//! doc\r\n\r\nmod seed;\r\nmod views;\r\n";
let out = declare_module(crlf, "mod commands;").expect("insert");
assert!(out.contains("mod commands;\r\nmod seed;"), "{out:?}");
assert!(
!out.contains("mod commands;\nmod seed;"),
"LF leaked in: {out:?}"
);
let no_nl = "mod seed;\nmod views;";
let out = declare_module(no_nl, "mod commands;").expect("insert");
assert!(
!out.ends_with('\n'),
"a trailing newline was added to a file that had none: {out:?}"
);
let out = insert_before_marker("a\r\n// MARK\r\n", "// MARK", "one\ntwo").expect("marker");
assert_eq!(out, "a\r\none\r\ntwo\r\n// MARK\r\n");
}
#[test]
fn resolve_target_names_the_owner_file_per_target() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
fs::create_dir_all(root.join("plugins/blog/src")).unwrap();
fs::write(root.join("plugins/blog/Cargo.toml"), "").unwrap();
fs::write(root.join("plugins/blog/src/lib.rs"), "").unwrap();
let r = resolve_target(root, &Target::Root).expect("root");
assert!(r.is_root);
assert_eq!(r.owner_file, root.join("src/main.rs"));
assert_eq!(r.module_decl("commands"), "mod commands;");
let p = resolve_target(root, &Target::Plugin("blog".into())).expect("plugin");
assert!(!p.is_root);
assert_eq!(p.owner_file, root.join("plugins/blog/src/lib.rs"));
assert_eq!(p.module_decl("commands"), "pub mod commands;");
match resolve_target(root, &Target::Plugin("blgo".into())) {
Err(CodegenError::NoSuchPlugin { asked, available }) => {
assert_eq!(asked, "blgo");
assert_eq!(available, vec!["blog".to_string()]);
}
other => panic!("expected NoSuchPlugin, got {other:?}"),
}
}
}