use std::fs;
use crate::cli::commands::InitArgs;
use crate::io::project_io;
use crate::ops::track_ops::generate_prefix;
const PROJECT_TOML_TEMPLATE: &str = include_str!("../../templates/project.toml");
const INBOX_TEMPLATE: &str = "# Inbox\n";
const TRACK_TEMPLATE: &str = "# {name}\n\n> \n\n## Backlog\n\n## Parked\n\n## Done\n";
fn validate_track_id(id: &str) -> Result<(), String> {
if id.is_empty() {
return Err("track id cannot be empty".to_string());
}
if !id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(format!(
"invalid track id \"{}\" — use lowercase with hyphens (e.g. \"my-track\")",
id
));
}
Ok(())
}
fn infer_name(dir_name: &str) -> String {
dir_name
.split('-')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(c) => {
let upper: String = c.to_uppercase().collect();
upper + &chars.collect::<String>()
}
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn parse_track_pairs(args: &[String]) -> Vec<(&str, &str)> {
args.chunks(2)
.filter_map(|chunk| {
if chunk.len() == 2 {
Some((chunk[0].as_str(), chunk[1].as_str()))
} else {
None
}
})
.collect()
}
fn render_project_toml(
name: &str,
tracks: &[(&str, &str)],
prefixes: &[(String, String)],
) -> String {
let mut output = PROJECT_TOML_TEMPLATE.replace("{{PROJECT_NAME}}", name);
if tracks.is_empty() {
return output;
}
for (id, tname) in tracks {
output.push_str(&format!(
"\n[[tracks]]\nid = \"{}\"\nname = \"{}\"\nstate = \"active\"\nfile = \"tracks/{}.md\"\n",
id, tname, id
));
}
output.push_str("\n[ids.prefixes]\n");
for (id, pfx) in prefixes {
output.push_str(&format!("{} = \"{}\"\n", id, pfx));
}
output
}
fn update_git_config(cwd: &std::path::Path) -> Vec<String> {
let report = crate::ops::git_setup::run(cwd, "frame", false);
report
.steps
.iter()
.filter(|s| s.changed_anything())
.map(|s| s.name.to_string())
.collect()
}
pub fn cmd_init(args: InitArgs) -> Result<(), Box<dyn std::error::Error>> {
let cwd = std::env::current_dir()?;
let frame_dir = cwd.join("frame");
if frame_dir.is_dir() && !args.force {
return Err("frame/ already exists (use --force to reinitialize)".into());
}
if let Some(parent) = cwd.parent()
&& let Ok(parent_root) = project_io::discover_project(parent)
{
let parent_frame = parent_root.join("frame");
eprintln!("Note: parent project found at {}/", parent_frame.display());
eprintln!("Creating new project in ./frame/");
}
let track_pairs = parse_track_pairs(&args.track);
for (id, _) in &track_pairs {
validate_track_id(id)?;
}
let mut seen_ids = std::collections::HashSet::new();
for (id, _) in &track_pairs {
if !seen_ids.insert(*id) {
return Err(format!("duplicate track id \"{}\"", id).into());
}
}
let name = args.name.unwrap_or_else(|| {
cwd.file_name()
.and_then(|n| n.to_str())
.map(infer_name)
.unwrap_or_else(|| "Untitled".to_string())
});
let mut prefixes = Vec::new();
let mut existing_prefixes: Vec<String> = Vec::new();
for (id, _) in &track_pairs {
let pfx = generate_prefix(id, &existing_prefixes);
existing_prefixes.push(pfx.clone());
prefixes.push((id.to_string(), pfx));
}
fs::create_dir_all(frame_dir.join("tracks"))?;
fs::create_dir_all(frame_dir.join("archive"))?;
let toml_content = render_project_toml(&name, &track_pairs, &prefixes);
fs::write(frame_dir.join("project.toml"), toml_content)?;
fs::write(frame_dir.join("inbox.md"), INBOX_TEMPLATE)?;
for (id, tname) in &track_pairs {
let content = TRACK_TEMPLATE.replace("{name}", tname);
fs::write(frame_dir.join(format!("tracks/{}.md", id)), content)?;
}
if !crate::io::actors::actors_path(&frame_dir).exists() {
let mut registry = crate::io::actors::ActorRegistry::default();
let actor_name = crate::io::actors::default_name();
registry
.claim("null", &actor_name, None, &crate::io::actors::today())
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
crate::io::actors::write_actors(&frame_dir, ®istry)?;
crate::io::actors::write_actor_token(&frame_dir, "null")?;
}
crate::io::registry::register_project(&name, &cwd);
let git_configured = update_git_config(&cwd);
println!("[>] frame initialized");
println!();
println!(" project.toml");
println!(" inbox.md");
for (id, _) in &track_pairs {
println!(" tracks/{}.md", id);
}
if !git_configured.is_empty() {
println!();
println!(" configured git: {}", git_configured.join(", "));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_track_id_valid() {
assert!(validate_track_id("effects").is_ok());
assert!(validate_track_id("compiler-infra").is_ok());
assert!(validate_track_id("v2").is_ok());
assert!(validate_track_id("my-cool-track").is_ok());
}
#[test]
fn test_validate_track_id_invalid() {
assert!(validate_track_id("My Track").is_err());
assert!(validate_track_id("UPPER").is_err());
assert!(validate_track_id("under_score").is_err());
assert!(validate_track_id("").is_err());
}
#[test]
fn test_infer_name() {
assert_eq!(infer_name("my-cool-project"), "My Cool Project");
assert_eq!(infer_name("frame"), "Frame");
assert_eq!(infer_name("lace-compiler"), "Lace Compiler");
}
#[test]
fn test_parse_track_pairs() {
let args = vec![
"effects".to_string(),
"Effect System".to_string(),
"infra".to_string(),
"Infrastructure".to_string(),
];
let pairs = parse_track_pairs(&args);
assert_eq!(pairs.len(), 2);
assert_eq!(pairs[0], ("effects", "Effect System"));
assert_eq!(pairs[1], ("infra", "Infrastructure"));
}
#[test]
fn test_template_embedding() {
assert!(!PROJECT_TOML_TEMPLATE.is_empty());
assert!(PROJECT_TOML_TEMPLATE.contains("{{PROJECT_NAME}}"));
}
#[test]
fn test_render_project_toml_no_tracks() {
let result = render_project_toml("My Project", &[], &[]);
assert!(result.contains("name = \"My Project\""));
assert!(!result.contains("{{PROJECT_NAME}}"));
assert!(!result.contains("\n[[tracks]]"));
assert!(!result.contains("\n[ids.prefixes]"));
assert!(result.contains("[clean]"));
assert!(result.contains("[ui]"));
}
#[test]
fn test_render_project_toml_with_tracks() {
let tracks = vec![("api", "API Layer"), ("ui", "UI")];
let prefixes = vec![
("api".to_string(), "API".to_string()),
("ui".to_string(), "UI".to_string()),
];
let result = render_project_toml("Test", &tracks, &prefixes);
assert!(result.contains("[[tracks]]"));
assert!(result.contains("id = \"api\""));
assert!(result.contains("name = \"API Layer\""));
assert!(result.contains("file = \"tracks/api.md\""));
assert!(result.contains("id = \"ui\""));
assert!(result.contains("name = \"UI\""));
assert!(result.contains("[ids.prefixes]"));
assert!(result.contains("api = \"API\""));
assert!(result.contains("ui = \"UI\""));
}
#[test]
fn test_render_prefix_collision() {
let tracks = vec![("api", "API Service"), ("app", "Application")];
let mut existing_prefixes: Vec<String> = Vec::new();
let mut prefixes = Vec::new();
for (id, _) in &tracks {
let pfx = generate_prefix(id, &existing_prefixes);
existing_prefixes.push(pfx.clone());
prefixes.push((id.to_string(), pfx));
}
assert_ne!(prefixes[0].1, prefixes[1].1);
let result = render_project_toml("Test", &tracks, &prefixes);
assert!(result.contains(&format!("api = \"{}\"", prefixes[0].1)));
assert!(result.contains(&format!("app = \"{}\"", prefixes[1].1)));
}
#[test]
fn test_render_round_trip_no_tracks() {
let result = render_project_toml("Round Trip", &[], &[]);
let parsed: crate::model::config::ProjectConfig = toml::from_str(&result).unwrap();
assert_eq!(parsed.project.name, "Round Trip");
}
#[test]
fn test_render_round_trip_with_tracks() {
let tracks = vec![("api", "API Layer"), ("ui", "UI")];
let prefixes = vec![
("api".to_string(), "API".to_string()),
("ui".to_string(), "UI".to_string()),
];
let result = render_project_toml("Round Trip", &tracks, &prefixes);
let parsed: crate::model::config::ProjectConfig = toml::from_str(&result).unwrap();
assert_eq!(parsed.project.name, "Round Trip");
assert_eq!(parsed.tracks.len(), 2);
assert_eq!(parsed.tracks[0].id, "api");
assert_eq!(parsed.tracks[1].id, "ui");
assert_eq!(parsed.ids.prefixes.get("api").unwrap(), "API");
assert_eq!(parsed.ids.prefixes.get("ui").unwrap(), "UI");
}
#[test]
fn test_update_git_config_no_git() {
let tmp = tempfile::TempDir::new().unwrap();
assert!(update_git_config(tmp.path()).is_empty());
assert!(!tmp.path().join(".gitignore").exists());
assert!(!tmp.path().join(".gitattributes").exists());
}
#[test]
fn test_update_git_config_configures_a_new_repo() {
let tmp = tempfile::TempDir::new().unwrap();
if !std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(tmp.path())
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return; }
let changed = update_git_config(tmp.path());
assert!(!changed.is_empty(), "a fresh repo needs configuring");
let gitignore = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(
gitignore.contains("frame/.*"),
"pattern missing: {gitignore}"
);
for name in crate::io::project_io::LOCAL_ONLY_FRAME_FILES {
assert!(
!gitignore.contains(&format!("frame/{name}")),
"should not enumerate {name}: {gitignore}"
);
}
let attrs = fs::read_to_string(tmp.path().join(".gitattributes")).unwrap();
assert!(attrs.contains("merge=frame"), "driver not routed: {attrs}");
assert!(update_git_config(tmp.path()).is_empty());
}
}