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_gitignore(cwd: &std::path::Path) -> bool {
if !cwd.join(".git").exists() {
return false;
}
let gitignore_path = cwd.join(".gitignore");
let existing = fs::read_to_string(&gitignore_path).unwrap_or_default();
let entries = ["frame/.state.json", "frame/.lock", "frame/.recovery.log"];
let mut to_add = Vec::new();
for entry in &entries {
if !existing.lines().any(|line| line.trim() == *entry) {
to_add.push(*entry);
}
}
if to_add.is_empty() {
return false;
}
let mut content = existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str("\n# frame (added by fr init)\n");
for entry in &to_add {
content.push_str(entry);
content.push('\n');
}
fs::write(&gitignore_path, content).is_ok()
}
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)?;
}
crate::io::registry::register_project(&name, &cwd);
let gitignore_updated = update_gitignore(&cwd);
println!("[>] frame initialized");
println!();
println!(" project.toml");
println!(" inbox.md");
for (id, _) in &track_pairs {
println!(" tracks/{}.md", id);
}
if gitignore_updated {
println!();
println!(" added frame/.state.json, frame/.lock to .gitignore");
}
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_gitignore_no_git() {
let tmp = tempfile::TempDir::new().unwrap();
assert!(!update_gitignore(tmp.path()));
assert!(!tmp.path().join(".gitignore").exists());
}
#[test]
fn test_update_gitignore_creates_entries() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".git")).unwrap();
assert!(update_gitignore(tmp.path()));
let content = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(content.contains("frame/.state.json"));
assert!(content.contains("frame/.lock"));
assert!(content.contains("# frame (added by fr init)"));
}
#[test]
fn test_update_gitignore_skips_existing() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".git")).unwrap();
fs::write(
tmp.path().join(".gitignore"),
"frame/.state.json\nframe/.lock\nframe/.recovery.log\n",
)
.unwrap();
assert!(!update_gitignore(tmp.path()));
}
#[test]
fn test_update_gitignore_appends_to_existing() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir(tmp.path().join(".git")).unwrap();
fs::write(tmp.path().join(".gitignore"), "*.log\n").unwrap();
assert!(update_gitignore(tmp.path()));
let content = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
assert!(content.starts_with("*.log\n"));
assert!(content.contains("frame/.state.json"));
assert!(content.contains("frame/.lock"));
}
}