Skip to main content

cargo_ravenrs/
ai.rs

1use crate::{SKILL, VERSION, read, write};
2use serde_json::{Value, json};
3use std::path::Path;
4use toml_edit::{DocumentMut, value};
5
6const START: &str = "<!-- ravenrs:begin -->";
7const END: &str = "<!-- ravenrs:end -->";
8const TOML_START: &str = "# ravenrs:begin";
9const TOML_END: &str = "# ravenrs:end";
10
11fn marked(previous: &str, body: &str, start: &str, end: &str) -> Result<String, String> {
12    let block = format!("{start}\n{body}\n{end}");
13    match (previous.find(start), previous.find(end)) {
14        (Some(a), Some(b))
15            if b > a
16                && !previous[a + start.len()..].contains(start)
17                && !previous[b + end.len()..].contains(end) =>
18        {
19            Ok(format!("{}{}{}", &previous[..a], block, &previous[b + end.len()..]))
20        }
21        (None, None) => Ok(format!(
22            "{previous}{}{block}\n",
23            if previous.is_empty() || previous.ends_with('\n') { "" } else { "\n" }
24        )),
25        _ => Err("Bloco RavenRS incompleto ou duplicado; corrija os marcadores antes de atualizar.".into()),
26    }
27}
28
29fn previous(root: &Path, path: &str) -> Result<String, String> {
30    if root.join(path).exists() { read(&root.join(path)) } else { Ok(String::new()) }
31}
32
33fn json_file(root: &Path, path: &str) -> Result<Value, String> {
34    let old = previous(root, path)?;
35    if old.is_empty() {
36        return Ok(json!({}));
37    }
38    let parsed: Value = serde_json::from_str(&old).map_err(|e| format!("{path}: {e}"))?;
39    if !parsed.is_object() {
40        return Err(format!("{path}: objeto JSON esperado"));
41    }
42    Ok(parsed)
43}
44
45/// Prepare AI configuration without changing anything outside RavenRS-owned entries or blocks.
46/// JSON is merged by key; Markdown and Codex TOML use explicit managed blocks.
47pub fn configure_ai(root: &Path, plugin: bool) -> Result<(), String> {
48    let files = ai_files(root, plugin)?;
49    let skill = root.join(".claude/skills/ravenrs/SKILL.md");
50    let remove_skill = plugin && skill.is_file();
51    if remove_skill && read(&skill)? != SKILL {
52        return Err("A skill local tem alterações. Mova suas notas antes de trocar para --plugin.".into());
53    }
54    for (path, contents) in files {
55        write(root, &path, &contents)?;
56    }
57    if remove_skill {
58        std::fs::remove_file(skill).map_err(|e| e.to_string())?;
59    }
60    println!("Arquivos de IA atualizados para RavenRS {VERSION}.");
61    Ok(())
62}
63
64pub(crate) fn ai_files(root: &Path, plugin: bool) -> Result<Vec<(String, String)>, String> {
65    let _ = read(&root.join("Cargo.toml"))?;
66    let mut files = Vec::new();
67    for (path, key, vscode) in [
68        (".mcp.json", "mcpServers", false),
69        (".cursor/mcp.json", "mcpServers", false),
70        (".vscode/mcp.json", "servers", true),
71    ] {
72        let mut config = json_file(root, path)?;
73        let mut server = json!({"command":"cargo", "args":["ravenrs", "mcp"]});
74        if vscode {
75            server["type"] = json!("stdio");
76        }
77        if config.get(key).is_some_and(|v| !v.is_object()) {
78            return Err(format!("{path}: {key} deve ser um objeto"));
79        }
80        if config.get(key).is_none() {
81            config[key] = json!({});
82        }
83        config[key]["ravenrs"] = server;
84        config["$comment"] = json!(format!("cargo-ravenrs {VERSION}; managed entry: {key}.ravenrs"));
85        files.push((
86            path.into(),
87            format!("{}\n", serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?),
88        ));
89    }
90    let path = ".codex/config.toml";
91    let old = previous(root, path)?;
92    let mut doc: DocumentMut =
93        if old.is_empty() { DocumentMut::new() } else { old.parse().map_err(|e| format!("{path}: {e}"))? };
94    if !old.contains(TOML_START) {
95        // Adopt only the RavenRS table; preserve all unrelated settings and comments.
96        if let Some(servers) = doc.get_mut("mcp_servers").and_then(toml_edit::Item::as_table_mut) {
97            servers.remove("ravenrs");
98        }
99    }
100    let old = if old.contains(TOML_START) { old } else { doc.to_string() };
101    let body = format!(
102        "# cargo-ravenrs {VERSION}\n[mcp_servers.ravenrs]\ncommand = \"cargo\"\nargs = [\"ravenrs\", \"mcp\"]"
103    );
104    let contents = marked(&old, &body, TOML_START, TOML_END)?;
105    let _: DocumentMut = contents.parse().map_err(|e| format!("{path}: {e}"))?;
106    files.push((path.into(), contents));
107
108    let manifest: DocumentMut =
109        read(&root.join("Cargo.toml"))?.parse().map_err(|e| format!("Cargo.toml: {e}"))?;
110    let name = manifest["package"]["name"].as_str().unwrap_or("app").replace('-', "_");
111    let web = root.join("web/index.html").is_file();
112    let body = format!(
113        r#"Generated by cargo-ravenrs {VERSION}.
114
115Run: `cargo run`. Test: `cargo test`. Check: `cargo check`.
116{web_commands}
117The main screen is `src/lib.rs` (`app()`); the desktop entry point is `src/main.rs`.
118Before writing API calls, consult the RavenRS MCP (`component_api`, `component_examples`).
119Use semantic design tokens, library components and `ravenrs-app` for record lists and forms.
120Never invent a method without checking the MCP. Keep project-specific instructions outside this block.
121"#,
122        web_commands = if web {
123            format!(
124                "Web: `cargo check --target wasm32-unknown-unknown`.\nBuild: `cargo build --release --lib --target wasm32-unknown-unknown`.\nInstall `wasm-bindgen-cli --version {}` with cargo, then run `wasm-bindgen --target web --out-dir web/pkg target/wasm32-unknown-unknown/release/{name}.wasm` and serve `web/` over HTTP.",
125                ravenrs_project::WASM_BINDGEN_VERSION
126            )
127        } else {
128            String::new()
129        }
130    );
131    files.push(("AGENTS.md".into(), marked(&previous(root, "AGENTS.md")?, &body, START, END)?));
132    files.push((
133        "CLAUDE.md".into(),
134        marked(&previous(root, "CLAUDE.md")?, &format!("cargo-ravenrs {VERSION}\n\n@AGENTS.md"), START, END)?,
135    ));
136
137    let path = ".claude/settings.json";
138    let mut settings = json_file(root, path)?;
139    settings["$comment"] = json!(format!("cargo-ravenrs {VERSION}"));
140    if settings.get("enabledMcpjsonServers").is_none() {
141        settings["enabledMcpjsonServers"] = json!([]);
142    }
143    let servers =
144        settings["enabledMcpjsonServers"].as_array_mut().ok_or("enabledMcpjsonServers deve ser uma lista")?;
145    if !servers.iter().any(|v| v.as_str() == Some("ravenrs")) {
146        servers.push(json!("ravenrs"));
147    }
148    if settings.get("permissions").is_none() {
149        settings["permissions"] = json!({});
150    }
151    if !settings["permissions"].is_object() {
152        return Err("permissions deve ser um objeto".into());
153    }
154    if settings["permissions"].get("allow").is_none() {
155        settings["permissions"]["allow"] = json!([]);
156    }
157    let allow =
158        settings["permissions"]["allow"].as_array_mut().ok_or("permissions.allow deve ser uma lista")?;
159    for rule in ["Bash(cargo check *)", "Bash(cargo test *)", "Bash(cargo run *)", "mcp__ravenrs__*"] {
160        if !allow.iter().any(|v| v.as_str() == Some(rule)) {
161            allow.push(json!(rule));
162        }
163    }
164    if plugin {
165        if settings.get("extraKnownMarketplaces").is_none() {
166            settings["extraKnownMarketplaces"] = json!({});
167        }
168        if settings.get("enabledPlugins").is_none() {
169            settings["enabledPlugins"] = json!({});
170        }
171        if !settings["extraKnownMarketplaces"].is_object() || !settings["enabledPlugins"].is_object() {
172            return Err("Configuração de plugins inválida".into());
173        }
174        settings["extraKnownMarketplaces"]["ravenrs"] =
175            json!({"source":{"source":"github","repo":"alexkads/RavenRS","ref":format!("v{VERSION}")}});
176        settings["enabledPlugins"]["ravenrs@ravenrs"] = json!(true);
177    } else {
178        // The skill remains byte-for-byte equal to its versioned plugin source.
179        let path = ".claude/skills/ravenrs/SKILL.md";
180        let old = previous(root, path)?;
181        let skill = if old.is_empty() {
182            SKILL.into()
183        } else {
184            let start = "<!-- ravenrs:skill:begin -->";
185            let end = "<!-- ravenrs:skill:end -->";
186            if !old.contains(start) {
187                return Err(format!(
188                    "{path}: skill sem marcadores; preserve seu conteúdo antes de adotar a skill gerenciada."
189                ));
190            }
191            let a = SKILL.find(start).ok_or("Skill sem marcador inicial")? + start.len();
192            let b = SKILL.find(end).ok_or("Skill sem marcador final")?;
193            marked(&old, SKILL[a..b].trim_matches('\n'), start, end)?
194        };
195        files.push((path.into(), skill));
196        if let Some(plugins) = settings.get_mut("enabledPlugins").and_then(Value::as_object_mut) {
197            plugins.remove("ravenrs@ravenrs");
198        }
199    }
200    files.push((
201        path.into(),
202        format!("{}\n", serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?),
203    ));
204    // State is separate so doctor can honor --plugin and --no-ai without guessing.
205    let mut state = DocumentMut::new();
206    state["version"] = value(VERSION);
207    state["plugin"] = value(plugin);
208    files.push((".ravenrs-ai.toml".into(), format!("# cargo-ravenrs {VERSION}\n{state}")));
209    Ok(files)
210}