use std::{ fs, io, path::{Path, PathBuf}, }; use crate::diag::Diagnostic; #[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum ProjectTemplate { Binary, Library, Workspace, } impl ProjectTemplate { pub fn parse(value: &str) -> Option { match value { "binary" => Some(Self::Binary), "library" => Some(Self::Library), "workspace" => Some(Self::Workspace), _ => None, } } } pub fn create_project( target: &str, explicit_name: Option<&str>, template: ProjectTemplate, ) -> Result<(), Diagnostic> { if target.trim().is_empty() { return Err(Diagnostic::new( target, "UsageError", "project directory must not be empty", )); } let root = Path::new(target); if root.is_file() { return Err(Diagnostic::new( target, "ProjectScaffoldBlocked", format!( "cannot create project `{}` because a file exists there", target ), )); } if root.is_dir() && has_entries(root)? { return Err(Diagnostic::new( target, "ProjectScaffoldBlocked", format!( "cannot create project `{}` because the directory is not empty", target ), ) .hint("choose an empty directory or a new project path")); } let raw_name = explicit_name .map(str::to_string) .unwrap_or_else(|| basename(root).unwrap_or_else(|| "slovo-project".to_string())); let name = sanitize_project_name(&raw_name); if name.is_empty() { return Err(Diagnostic::new( target, "InvalidProjectName", "project name must contain at least one ASCII letter or digit", )); } match template { ProjectTemplate::Binary => create_binary_project(root, target, &name), ProjectTemplate::Library => create_library_project(root, target, &name), ProjectTemplate::Workspace => create_workspace_project(root, target), } } fn create_binary_project(root: &Path, target: &str, name: &str) -> Result<(), Diagnostic> { let src = root.join("src"); create_dir_all_checked(&src, target)?; write_checked( &root.join("slovo.toml"), &format!( "[project]\nname = \"{}\"\nsource_root = \"src\"\nentry = \"main\"\n", name ), target, )?; write_checked( &src.join("main.slo"), "(module main)\n\n(fn main () -> i32\n 0)\n\n(test \"main returns zero\"\n (= (main) 0))\n", target, ) } fn create_library_project(root: &Path, target: &str, name: &str) -> Result<(), Diagnostic> { let src = root.join("src"); create_dir_all_checked(&src, target)?; write_checked( &root.join("slovo.toml"), &format!( "[project]\nname = \"{}\"\nsource_root = \"src\"\nentry = \"lib\"\n", name ), target, )?; write_checked( &src.join("lib.slo"), "(module lib (export answer double))\n\n(fn answer () -> i32\n 42)\n\n(fn double ((value i32)) -> i32\n (+ value value))\n\n(test \"answer is stable\"\n (= (answer) 42))\n\n(test \"double works\"\n (= (double 21) 42))\n", target, ) } fn create_workspace_project(root: &Path, target: &str) -> Result<(), Diagnostic> { let app_src = root.join("packages/app/src"); let lib_src = root.join("packages/libutil/src"); create_dir_all_checked(&app_src, target)?; create_dir_all_checked(&lib_src, target)?; write_checked( &root.join("slovo.toml"), "[workspace]\nmembers = [\"packages/app\", \"packages/libutil\"]\ndefault_package = \"app\"\n", target, )?; write_checked( &root.join("packages/libutil/slovo.toml"), "[package]\nname = \"libutil\"\nversion = \"0.1.0\"\n", target, )?; write_checked( &root.join("packages/libutil/src/libutil.slo"), "(module libutil (export answer label))\n\n(fn answer () -> i32\n 42)\n\n(fn label () -> string\n \"libutil\")\n\n(test \"answer is stable\"\n (= (answer) 42))\n", target, )?; write_checked( &root.join("packages/app/slovo.toml"), "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[dependencies]\nlibutil = { path = \"../libutil\" }\n", target, )?; write_checked( &root.join("packages/app/src/main.slo"), "(module main)\n\n(import libutil.libutil (answer label))\n\n(fn main () -> i32\n (if (= (answer) 42)\n 0\n 1))\n\n(test \"app uses libutil\"\n (if (= (label) \"libutil\")\n (= (answer) 42)\n false))\n", target, ) } fn has_entries(path: &Path) -> Result { let mut entries = fs::read_dir(path).map_err(|err| io_diagnostic(path, err))?; match entries.next() { Some(Ok(_)) => Ok(true), Some(Err(err)) => Err(io_diagnostic(path, err)), None => Ok(false), } } fn basename(path: &Path) -> Option { path.file_name() .and_then(|name| name.to_str()) .map(str::to_string) } fn sanitize_project_name(value: &str) -> String { let mut out = String::new(); let mut previous_dash = false; for ch in value.chars().flat_map(char::to_lowercase) { let mapped = if ch.is_ascii_lowercase() || ch.is_ascii_digit() { Some(ch) } else if ch == '-' || ch == '_' || ch == '.' || ch.is_whitespace() { Some('-') } else { None }; let Some(ch) = mapped else { continue; }; if ch == '-' { if !out.is_empty() && !previous_dash { out.push('-'); previous_dash = true; } } else { out.push(ch); previous_dash = false; } } while out.ends_with('-') { out.pop(); } if out .chars() .next() .is_some_and(|first| first.is_ascii_digit()) { out.insert_str(0, "project-"); } out } fn create_dir_all_checked(path: &PathBuf, target: &str) -> Result<(), Diagnostic> { fs::create_dir_all(path).map_err(|err| { Diagnostic::new( target, "ProjectScaffoldBlocked", format!("cannot create directory `{}`: {}", path.display(), err), ) }) } fn write_checked(path: &Path, text: &str, target: &str) -> Result<(), Diagnostic> { fs::write(path, text).map_err(|err| { Diagnostic::new( target, "ProjectScaffoldBlocked", format!("cannot write `{}`: {}", path.display(), err), ) }) } fn io_diagnostic(path: &Path, err: io::Error) -> Diagnostic { Diagnostic::new( path.display().to_string(), "ProjectScaffoldBlocked", format!("cannot inspect `{}`: {}", path.display(), err), ) }