Skip to main content

cargo_ravenrs/
project.rs

1use crate::{TOOLCHAIN, VERSION, configure_ai, read, run, write};
2use ravenrs_project::{Project, ProjectFile};
3use std::path::Path;
4use toml_edit::{DocumentMut, Item, Value, value};
5
6const WORKSPACE: &str = include_str!("../../../Cargo.toml");
7macro_rules! example {
8    ($name:literal) => {
9        (
10            include_str!(concat!("../../../examples/", $name, "/Cargo.toml")),
11            include_str!(concat!("../../../examples/", $name, "/src/lib.rs")),
12        )
13    };
14}
15
16/// Render a template without writing anything. Sources come directly from the examples.
17pub fn project_files(name: &str, template: &str, target: &str) -> Result<Vec<ProjectFile>, String> {
18    if !valid_name(name) {
19        return Err("Nome inválido: use uma letra, seguida de letras ASCII, números, - ou _.".into());
20    }
21    if !matches!(target, "desktop" | "web" | "both") {
22        return Err("Alvo inválido: desktop, web ou both.".into());
23    }
24    let (manifest, source) = match template {
25        "hello" => example!("hello-world"),
26        "app" => example!("tutorial-tasks"),
27        "app-sqlite" => example!("tasks-sqlite"),
28        "relatorios" => example!("report-designer"),
29        _ => return Err("Modelo inválido: hello, app, app-sqlite ou relatorios.".into()),
30    };
31    let project = Project::new(name).source(source);
32    let normalized = project.crate_name().replace('-', "_");
33    let krate = name.replace('-', "_");
34    let mut files = project.files_from_app();
35    // Playground titles are normalized; Cargo names keep case and repeated separators.
36    for file in &mut files {
37        let suffix = match file.path.as_str() {
38            "src/main.rs" => Some((format!("{normalized}::app()"), format!("{krate}::app()"))),
39            "web/index.html" => Some((format!("pkg/{normalized}.js"), format!("pkg/{krate}.js"))),
40            "README.md" => Some((format!("/{normalized}.wasm"), format!("/{krate}.wasm"))),
41            _ => None,
42        };
43        if let Some((from, to)) = suffix {
44            file.contents = file.contents.replace(&from, &to);
45        }
46    }
47    let mut manifest: DocumentMut = manifest.parse().map_err(|e| format!("Modelo: {e}"))?;
48    let workspace: DocumentMut = WORKSPACE.parse().map_err(|e| format!("Workspace: {e}"))?;
49    manifest["package"]["name"] = value(name);
50    manifest["package"]["version"] = value("0.1.0");
51    for key in ["edition", "rust-version", "license"] {
52        if manifest["package"][key].get("workspace").and_then(Item::as_bool) == Some(true) {
53            manifest["package"][key] = workspace["workspace"]["package"][key].clone();
54        }
55    }
56    manifest.as_table_mut().remove("lints");
57    manifest.as_table_mut().remove("dev-dependencies");
58    resolve_dependencies(&mut manifest["dependencies"], &workspace)?;
59    if let Some(targets) = manifest.get_mut("target").and_then(Item::as_table_mut) {
60        for (_, item) in targets.iter_mut() {
61            resolve_dependencies(&mut item["dependencies"], &workspace)?;
62        }
63        if target == "desktop" {
64            targets.remove("cfg(target_arch = \"wasm32\")");
65        }
66    }
67    let manifest = format!("# Generated by cargo-ravenrs {VERSION}\n{manifest}");
68    for file in &mut files {
69        if file.path == "Cargo.toml" {
70            file.contents = manifest.clone();
71        }
72        if file.path == "README.md" {
73            file.contents = file.contents.replace(
74                "Made in the RavenRS playground.",
75                &format!("Generated by cargo-ravenrs {VERSION} ({template}, {target})."),
76            );
77        }
78    }
79    if target == "desktop" {
80        files.retain(|f| f.path != "web/index.html");
81    }
82    let mut toolchain: DocumentMut = TOOLCHAIN.parse().map_err(|e| format!("Toolchain: {e}"))?;
83    if target == "desktop" {
84        toolchain["toolchain"].as_table_mut().ok_or("toolchain inválida")?.remove("targets");
85    }
86    files.push(ProjectFile {
87        path: "rust-toolchain.toml".into(),
88        contents: format!("# cargo-ravenrs {VERSION}\n{toolchain}"),
89    });
90    files.push(ProjectFile {
91        path: "tests/screen.rs".into(),
92        contents: format!(
93            r#"// cargo-ravenrs {VERSION}
94#[test]
95fn main_screen_renders() {{
96    use ravenrs::prelude::*;
97    let mut ui = Ui::new({krate}::app(), Theme::raven_dark());
98    ui.set_viewport(ravenrs::core::Size::new(1200.0, 800.0), 1.0);
99    ui.update();
100    assert!(!ui.scene().primitives().is_empty());
101}}
102"#,
103            krate = name.replace('-', "_")
104        ),
105    });
106    Ok(files)
107}
108
109fn valid_name(name: &str) -> bool {
110    const RESERVED: &[&str] = &[
111        "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn", "for", "if",
112        "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref", "return", "self", "Self",
113        "static", "struct", "super", "trait", "true", "type", "unsafe", "use", "where", "while", "async",
114        "await", "dyn", "abstract", "become", "box", "do", "final", "gen", "macro", "override", "priv",
115        "typeof", "unsized", "virtual", "yield", "try", "test", "lib", "std", "core", "alloc", "con", "prn",
116        "aux", "nul",
117    ];
118    let lower = name.to_ascii_lowercase();
119    name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
120        && name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
121        && !RESERVED.contains(&name)
122        && !RESERVED.contains(&lower.as_str())
123        && !(lower.len() == 4
124            && (lower.starts_with("com") || lower.starts_with("lpt"))
125            && lower.as_bytes()[3].is_ascii_digit())
126}
127
128fn resolve_dependencies(item: &mut Item, workspace: &DocumentMut) -> Result<(), String> {
129    let Some(deps) = item.as_table_mut() else {
130        return Ok(());
131    };
132    for (name, dependency) in deps.iter_mut() {
133        if dependency.get("workspace").and_then(Item::as_bool) != Some(true) {
134            continue;
135        }
136        let original = dependency.clone();
137        *dependency = workspace["workspace"]["dependencies"]
138            .get(&*name)
139            .ok_or_else(|| format!("Dependência ausente: {name}"))?
140            .clone();
141        if let Some(table) = dependency.as_inline_table_mut() {
142            table.remove("path");
143            if let Some(extra) = original.get("features").and_then(Item::as_array) {
144                let features = table
145                    .entry("features")
146                    .or_insert(Value::Array(Default::default()))
147                    .as_array_mut()
148                    .ok_or("features inválidas")?;
149                for feature in extra.iter() {
150                    features.push(feature.clone());
151                }
152            }
153        } else if let Some(version) = dependency.as_str() {
154            let mut table = toml_edit::InlineTable::new();
155            table.insert("version", version.into());
156            if let Some(features) = original.get("features").and_then(Item::as_value) {
157                table.insert("features", features.clone());
158            }
159            *dependency = Item::Value(Value::InlineTable(table));
160        }
161        if name.starts_with("ravenrs") {
162            dependency["version"] = value(format!("={VERSION}"));
163        }
164    }
165    Ok(())
166}
167
168/// Create a project, optionally initialize Git and AI, check every chosen target and probe MCP.
169pub fn new_project(
170    path: &Path,
171    template: &str,
172    target: &str,
173    git: bool,
174    ai: bool,
175    check: bool,
176    plugin: bool,
177) -> Result<(), String> {
178    let name = path.file_name().and_then(|s| s.to_str()).ok_or("Nome de projeto inválido")?;
179    let files = project_files(name, template, target)?;
180    if path.exists() {
181        // Never overwrite user code, including on repeated bootstrap runs.
182        let expected = format!("# Generated by cargo-ravenrs {VERSION}");
183        if !path.join("Cargo.toml").is_file() || !read(&path.join("Cargo.toml"))?.starts_with(&expected) {
184            return Err(format!("{} já existe e não foi gerado por esta versão.", path.display()));
185        }
186        let state: DocumentMut = read(&path.join(".ravenrs-project.toml"))?
187            .parse()
188            .map_err(|e| format!("Metadados do projeto inválidos: {e}"))?;
189        let previous_template =
190            state.get("template").and_then(Item::as_str).ok_or("Modelo original ausente nos metadados")?;
191        let previous_target =
192            state.get("target").and_then(Item::as_str).ok_or("Alvo original ausente nos metadados")?;
193        if previous_template != template || previous_target != target {
194            return Err(format!(
195                "{} já contém um projeto {previous_template}/{previous_target}; você escolheu {template}/{target}. Use outra pasta para criar esse modelo/alvo. O projeto existente não foi alterado.",
196                path.display()
197            ));
198        }
199        println!("Projeto existente: preservando o código.");
200    } else {
201        std::fs::create_dir_all(path).map_err(|e| e.to_string())?;
202        for file in files {
203            write(path, &file.path, &file.contents)?;
204        }
205        write(
206            path,
207            ".ravenrs-project.toml",
208            &format!(
209                "# cargo-ravenrs {VERSION}\nversion = \"{VERSION}\"\ntemplate = \"{template}\"\ntarget = \"{target}\"\nai = {ai}\n"
210            ),
211        )?;
212    }
213    if git && !path.join(".git").exists() {
214        run(path, "git", &["init"])?;
215    }
216    if ai {
217        configure_ai(path, plugin)?;
218    }
219    if check {
220        if target != "web" {
221            run(path, "cargo", &["check"])?;
222        }
223        if target != "desktop" {
224            run(path, "cargo", &["check", "--target", "wasm32-unknown-unknown"])?;
225        }
226    }
227    crate::mcp::probe("cargo", &["ravenrs".into(), "mcp".into()], path)?;
228    let next = if target == "web" {
229        "cargo check --target wasm32-unknown-unknown  # veja README.md para servir web/"
230    } else {
231        "cargo run"
232    };
233    println!("Pronto.\n  cd {}\n  {next}\n  claude  # ou code .\n  cargo ravenrs doctor", path.display());
234    Ok(())
235}