Skip to main content

nika_cli/machine/
install.rs

1//! Editor detection, rule installation, hash protection, completions, and quick scan.
2
3use std::collections::HashMap;
4use std::path::Path;
5use std::process::Command;
6
7use colored::Colorize;
8
9use super::status::{machine_toml_path, write_marker, SetupResult};
10
11// ─── Editor Detection ────────────────────────────────────────────────────────
12
13/// Detect which AI-capable editors are installed on this machine.
14///
15/// Returns a list of editor IDs (lowercase slugs) used as keys for rule
16/// installation and tracking in machine.toml.
17pub(super) fn detect_editors() -> Vec<&'static str> {
18    let mut editors = Vec::new();
19    let home = dirs::home_dir().unwrap_or_default();
20
21    // VS Code
22    if which::which("code").is_ok() || check_macos_app("VS Code") {
23        editors.push("vscode");
24    }
25    // Cursor
26    if which::which("cursor").is_ok() || home.join(".cursor").exists() || check_macos_app("Cursor")
27    {
28        editors.push("cursor");
29    }
30    // Claude Code
31    if which::which("claude").is_ok() || home.join(".claude").exists() {
32        editors.push("claude");
33    }
34    // Windsurf
35    if which::which("windsurf").is_ok() || check_macos_app("Windsurf") {
36        editors.push("windsurf");
37    }
38    // Roo Code
39    if home.join(".roo").exists() {
40        editors.push("roo");
41    }
42    // Copilot (via GitHub CLI — the only reliable signal)
43    if which::which("gh").is_ok() {
44        editors.push("copilot");
45    }
46
47    editors
48}
49
50/// Run the full machine auto-setup (Phase 1).
51///
52/// Detects editors and AI tools, installs extensions/rules/completions.
53/// Prints progress as it goes. Writes marker file on success.
54/// This is SILENT by design — no questions asked, just detect and install.
55pub fn run_machine_setup() -> Vec<SetupResult> {
56    let mut results = Vec::new();
57
58    // 1. Editors: detect + install extension
59    results.extend(setup_editors());
60
61    // 2. AI tools: detect + install rules
62    results.extend(setup_ai_rules());
63
64    // 3. Shell completions
65    results.push(setup_completions());
66
67    // Write marker file
68    write_marker(&results);
69
70    // Summary: show each configured editor by name
71    let ok_results: Vec<&SetupResult> = results.iter().filter(|r| r.success).collect();
72    if ok_results.is_empty() {
73        println!("  {} No editors detected", "\u{25cb}".dimmed());
74    } else {
75        println!("  {}", "AI Editors configured:".bold());
76        for r in &ok_results {
77            println!("    {} {}", "\u{2713}".green(), r.name);
78        }
79    }
80
81    results
82}
83
84fn setup_editors() -> Vec<SetupResult> {
85    let mut results = Vec::new();
86
87    let editors: &[(&str, &str, &str)] = &[
88        ("VS Code", "code", "supernovae.nika-lang"),
89        ("Cursor", "cursor", "supernovae.nika-lang"),
90        ("Windsurf", "windsurf", "supernovae.nika-lang"),
91    ];
92
93    for (name, binary, ext_id) in editors {
94        // Check binary in PATH or macOS .app
95        let has_binary = which::which(binary).is_ok() || check_macos_app(name);
96        if !has_binary {
97            continue;
98        }
99
100        // Check if extension already installed
101        let has_ext = Command::new(binary)
102            .args(["--list-extensions"])
103            .output()
104            .ok()
105            .and_then(|o| String::from_utf8(o.stdout).ok())
106            .map(|list| list.lines().any(|l| l.eq_ignore_ascii_case(ext_id)))
107            .unwrap_or(false);
108
109        if has_ext {
110            println!("  {} {} + nika-lang extension", "\u{2713}".green(), name);
111            results.push(SetupResult {
112                name: name.to_string(),
113                success: true,
114                message: "already installed".into(),
115            });
116            continue;
117        }
118
119        // Install extension
120        print!("  {} {} — installing nika-lang...", "\u{25c7}".cyan(), name);
121        let install = Command::new(binary)
122            .args(["--install-extension", ext_id])
123            .output();
124
125        match install {
126            Ok(output) if output.status.success() => {
127                println!(
128                    "\r  {} {} — nika-lang installed       ",
129                    "\u{2713}".green(),
130                    name
131                );
132                results.push(SetupResult {
133                    name: name.to_string(),
134                    success: true,
135                    message: "installed".into(),
136                });
137            }
138            _ => {
139                println!(
140                    "\r  {} {} — install failed          ",
141                    "\u{2717}".red(),
142                    name
143                );
144                results.push(SetupResult {
145                    name: name.to_string(),
146                    success: false,
147                    message: format!("run: {} --install-extension {}", binary, ext_id),
148                });
149            }
150        }
151    }
152
153    if results.is_empty() {
154        println!(
155            "  {} No editors detected (VS Code, Cursor, Windsurf)",
156            "\u{25cb}".dimmed()
157        );
158    }
159
160    results
161}
162
163#[cfg(target_os = "macos")]
164fn check_macos_app(name: &str) -> bool {
165    let app_names: &[&str] = match name {
166        "VS Code" => &["Visual Studio Code"],
167        "Cursor" => &["Cursor"],
168        "Windsurf" => &["Windsurf"],
169        _ => return false,
170    };
171    app_names.iter().any(|app| {
172        std::path::Path::new(&format!("/Applications/{}.app", app)).exists()
173            || dirs::home_dir()
174                .map(|h| h.join(format!("Applications/{}.app", app)).exists())
175                .unwrap_or(false)
176    })
177}
178
179#[cfg(not(target_os = "macos"))]
180fn check_macos_app(_name: &str) -> bool {
181    false
182}
183
184/// Comprehensive Nika rules for Claude Code (~/.claude/rules/nika.md).
185const CLAUDE_RULES_CONTENT: &str = include_str!("../../rules/claude.md");
186
187/// Unified Nika rules for Cursor (~/.cursor/rules/nika.mdc).
188///
189/// Merges syntax, patterns, architecture, and security into one comprehensive
190/// .mdc file triggered on *.nika.yaml files.
191const CURSOR_NIKA_RULES: &str = include_str!("../../rules/cursor.mdc");
192
193/// Nika rules for Copilot (~/.github/copilot/nika.instructions.md).
194const COPILOT_RULES: &str = include_str!("../../rules/copilot.md");
195
196/// Nika rules for Windsurf (~/.windsurf/rules/nika.md).
197const WINDSURF_RULES: &str = include_str!("../../rules/windsurf.md");
198
199/// Nika rules for Roo Code (~/.roo/rules/nika.md).
200const ROO_RULES: &str = include_str!("../../rules/roo.md");
201
202// ─── Content Hash Fingerprinting ─────────────────────────────────────────────
203
204fn hash_content(content: &str) -> String {
205    format!("{:016x}", xxhash_rust::xxh3::xxh3_64(content.as_bytes()))
206}
207
208fn load_rule_hashes() -> HashMap<String, String> {
209    let content = match std::fs::read_to_string(machine_toml_path()) {
210        Ok(c) => c,
211        Err(_) => return HashMap::new(),
212    };
213    let mut in_section = false;
214    let mut map = HashMap::new();
215    for line in content.lines() {
216        let t = line.trim();
217        if t == "[rule_hashes]" {
218            in_section = true;
219            continue;
220        }
221        if t.starts_with('[') {
222            in_section = false;
223            continue;
224        }
225        if !in_section {
226            continue;
227        }
228        if let Some((k, v)) = t.split_once('=') {
229            let key = k.trim().to_string();
230            let val = v.trim().trim_matches('"').to_string();
231            if !key.is_empty() && !val.is_empty() {
232                map.insert(key, val);
233            }
234        }
235    }
236    map
237}
238
239fn update_rule_hash(editor_key: &str, hash: &str) {
240    let path = machine_toml_path();
241    let content = match std::fs::read_to_string(&path) {
242        Ok(c) => c,
243        Err(_) => return,
244    };
245    let new_line = format!("{} = \"{}\"", editor_key, hash);
246    let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
247    let section_idx = lines.iter().position(|l| l.trim() == "[rule_hashes]");
248    match section_idx {
249        Some(idx) => {
250            // Find existing key in section
251            let key_idx = lines[idx + 1..]
252                .iter()
253                .position(|l| {
254                    let t = l.trim();
255                    t.starts_with(editor_key) && t[editor_key.len()..].trim_start().starts_with('=')
256                })
257                .map(|i| i + idx + 1);
258            match key_idx {
259                Some(ki) => lines[ki] = new_line,
260                None => lines.insert(idx + 1, new_line),
261            }
262        }
263        None => {
264            lines.push(String::new());
265            lines.push("[rule_hashes]".to_string());
266            lines.push(new_line);
267        }
268    }
269    std::fs::write(&path, lines.join("\n") + "\n").ok();
270}
271
272fn setup_ai_rules() -> Vec<SetupResult> {
273    let home = dirs::home_dir();
274    if home.is_none() {
275        return vec![SetupResult {
276            name: "AI Rules".into(),
277            success: false,
278            message: "cannot determine home directory".into(),
279        }];
280    }
281    let home = home.unwrap();
282    let mut results = Vec::new();
283    let editors = detect_editors();
284    let hashes = load_rule_hashes();
285
286    // Claude Code
287    if editors.contains(&"claude") {
288        install_rule(
289            &home.join(".claude/rules/nika.md"),
290            CLAUDE_RULES_CONTENT,
291            "Claude Code",
292            "claude",
293            &hashes,
294            &mut results,
295            false,
296        );
297    }
298
299    // Cursor
300    if editors.contains(&"cursor") {
301        install_rule(
302            &home.join(".cursor/rules/nika.mdc"),
303            CURSOR_NIKA_RULES,
304            "Cursor",
305            "cursor",
306            &hashes,
307            &mut results,
308            false,
309        );
310    }
311
312    // Copilot
313    if editors.contains(&"copilot") {
314        install_rule(
315            &home.join(".github/copilot/nika.instructions.md"),
316            COPILOT_RULES,
317            "Copilot",
318            "copilot",
319            &hashes,
320            &mut results,
321            false,
322        );
323    }
324
325    // Windsurf
326    if editors.contains(&"windsurf") {
327        install_rule(
328            &home.join(".windsurf/rules/nika.md"),
329            WINDSURF_RULES,
330            "Windsurf",
331            "windsurf",
332            &hashes,
333            &mut results,
334            false,
335        );
336    }
337
338    // Roo Code
339    if editors.contains(&"roo") {
340        install_rule(
341            &home.join(".roo/rules/nika.md"),
342            ROO_RULES,
343            "Roo Code",
344            "roo",
345            &hashes,
346            &mut results,
347            false,
348        );
349    }
350
351    // Agent Skills: install to ~/.agents/skills/
352    let skills_dir = home.join(".agents/skills");
353    let has_skills = skills_dir.join("nika-workflow-syntax").exists();
354    if !has_skills {
355        let skill_dir = skills_dir.join("nika-workflow-syntax");
356        std::fs::create_dir_all(&skill_dir).ok();
357        let skill_content = concat!(
358            "# Nika Workflow Syntax\n\n",
359            "Refer to AGENTS.md in any Nika project ",
360            "for the complete workflow syntax reference.\n"
361        );
362        if std::fs::write(skill_dir.join("SKILL.md"), skill_content).is_ok() {
363            println!(
364                "  {} Agent Skills installed [~/.agents/skills/]",
365                "\u{2713}".green()
366            );
367            results.push(SetupResult {
368                name: "Agent Skills".into(),
369                success: true,
370                message: "installed".into(),
371            });
372        }
373    } else {
374        println!("  {} Agent Skills [~/.agents/skills/]", "\u{2713}".green());
375        results.push(SetupResult {
376            name: "Agent Skills".into(),
377            success: true,
378            message: "present".into(),
379        });
380    }
381
382    results
383}
384
385/// Install a rule file for an editor with content-hash protection.
386///
387/// - If file exists and content matches expected -> skip ("up to date")
388/// - If file exists and disk hash doesn't match stored hash -> skip with warning
389///   ("user-customized")
390/// - Otherwise -> write + update hash
391///
392/// When `silent` is false, prints progress and pushes to `results`.
393/// When `silent` is true, returns quietly (used by quick_editor_scan).
394fn install_rule(
395    path: &Path,
396    content: &str,
397    name: &str,
398    editor_key: &str,
399    hashes: &HashMap<String, String>,
400    results: &mut Vec<SetupResult>,
401    silent: bool,
402) {
403    let expected_hash = hash_content(content);
404
405    // If file exists, check hashes before overwriting
406    if path.exists() {
407        if let Ok(disk_content) = std::fs::read_to_string(path) {
408            let disk_hash = hash_content(&disk_content);
409
410            // Content already matches — nothing to do
411            if disk_hash == expected_hash {
412                if !silent {
413                    println!("  {} {} — up to date", "\u{2713}".green(), name);
414                    results.push(SetupResult {
415                        name: name.into(),
416                        success: true,
417                        message: "up to date".into(),
418                    });
419                }
420                return;
421            }
422
423            // File differs from expected. Was it customized by the user?
424            if let Some(stored_hash) = hashes.get(editor_key) {
425                if disk_hash != *stored_hash {
426                    if !silent {
427                        println!(
428                            "  {} {} — user-customized, skipping",
429                            "\u{26a0}".yellow(),
430                            name
431                        );
432                        results.push(SetupResult {
433                            name: name.into(),
434                            success: true,
435                            message: "user-customized, preserved".into(),
436                        });
437                    }
438                    return;
439                }
440            }
441        }
442    }
443
444    if let Some(parent) = path.parent() {
445        std::fs::create_dir_all(parent).ok();
446    }
447    match std::fs::write(path, content) {
448        Ok(()) => {
449            update_rule_hash(editor_key, &expected_hash);
450            if !silent {
451                println!("  {} {} — Nika rules installed", "\u{2713}".green(), name);
452                results.push(SetupResult {
453                    name: name.into(),
454                    success: true,
455                    message: "installed".into(),
456                });
457            }
458        }
459        Err(e) => {
460            if !silent {
461                println!("  {} {} — write failed: {}", "\u{2717}".red(), name, e);
462                results.push(SetupResult {
463                    name: name.into(),
464                    success: false,
465                    message: format!("write failed: {}", e),
466                });
467            }
468        }
469    }
470}
471
472fn setup_completions() -> SetupResult {
473    let home = match dirs::home_dir() {
474        Some(h) => h,
475        None => {
476            return SetupResult {
477                name: "Completions".into(),
478                success: false,
479                message: "cannot determine home directory".into(),
480            };
481        }
482    };
483    let shell = std::env::var("SHELL").unwrap_or_default();
484    let shell_name = if shell.contains("zsh") {
485        "zsh"
486    } else if shell.contains("bash") {
487        "bash"
488    } else if shell.contains("fish") {
489        "fish"
490    } else {
491        return SetupResult {
492            name: "Completions".into(),
493            success: false,
494            message: "unknown shell".into(),
495        };
496    };
497
498    // Check if nika completion command exists
499    let output = Command::new("nika")
500        .args(["completion", shell_name])
501        .output();
502
503    match output {
504        Ok(o) if o.status.success() && !o.stdout.is_empty() => {
505            // Write completions to appropriate location
506            let target = match shell_name {
507                "zsh" => {
508                    let zfunc = home.join(".zfunc");
509                    std::fs::create_dir_all(&zfunc).ok();
510                    Some(zfunc.join("_nika"))
511                }
512                "bash" => {
513                    let dir = home.join(".local/share/bash-completion/completions");
514                    std::fs::create_dir_all(&dir).ok();
515                    Some(dir.join("nika"))
516                }
517                "fish" => {
518                    let dir = dirs::config_dir()
519                        .unwrap_or_default()
520                        .join("fish/completions");
521                    std::fs::create_dir_all(&dir).ok();
522                    Some(dir.join("nika.fish"))
523                }
524                _ => None,
525            };
526
527            if let Some(target) = target {
528                if std::fs::write(&target, &o.stdout).is_ok() {
529                    println!(
530                        "  {} {} completions installed",
531                        "\u{2713}".green(),
532                        shell_name
533                    );
534                    return SetupResult {
535                        name: "Completions".into(),
536                        success: true,
537                        message: format!("{} completions at {}", shell_name, target.display()),
538                    };
539                }
540            }
541
542            SetupResult {
543                name: "Completions".into(),
544                success: false,
545                message: "could not write completions".into(),
546            }
547        }
548        _ => {
549            println!(
550                "  {} {} completions (nika completion not available)",
551                "\u{25cb}".dimmed(),
552                shell_name
553            );
554            SetupResult {
555                name: "Completions".into(),
556                success: false,
557                message: "nika completion command not available".into(),
558            }
559        }
560    }
561}
562
563// ─── Quick Editor Re-Scan ────────────────────────────────────────────────────
564
565/// 24-hour cooldown in seconds for quick_editor_scan.
566const SCAN_COOLDOWN_SECS: u64 = 86_400;
567
568/// Lightweight scan for newly installed editors. Called on every nika command
569/// when machine_setup_status() == Ready. If a new editor is detected that
570/// wasn't in machine.toml, install rules silently and update the stored list.
571///
572/// Skips if last scan was < 24h ago (cooldown to avoid repeated filesystem checks).
573pub fn quick_editor_scan() {
574    // Cooldown: skip if scanned recently
575    if let Some(last) = read_last_scan_at() {
576        let now = std::time::SystemTime::now()
577            .duration_since(std::time::UNIX_EPOCH)
578            .unwrap_or_default()
579            .as_secs();
580        if now.saturating_sub(last) < SCAN_COOLDOWN_SECS {
581            return;
582        }
583    }
584
585    let current = detect_editors();
586    let stored = read_stored_editors();
587
588    let new_editors: Vec<&&str> = current
589        .iter()
590        .filter(|e| !stored.iter().any(|s| s == *e))
591        .collect();
592
593    if new_editors.is_empty() {
594        return;
595    }
596
597    let home = dirs::home_dir().unwrap_or_default();
598    let hashes = load_rule_hashes();
599    let mut results = Vec::new();
600    for editor in &new_editors {
601        match **editor {
602            "claude" => install_rule(
603                &home.join(".claude/rules/nika.md"),
604                CLAUDE_RULES_CONTENT,
605                "Claude Code",
606                "claude",
607                &hashes,
608                &mut results,
609                true,
610            ),
611            "cursor" => install_rule(
612                &home.join(".cursor/rules/nika.mdc"),
613                CURSOR_NIKA_RULES,
614                "Cursor",
615                "cursor",
616                &hashes,
617                &mut results,
618                true,
619            ),
620            "copilot" => install_rule(
621                &home.join(".github/copilot/nika.instructions.md"),
622                COPILOT_RULES,
623                "Copilot",
624                "copilot",
625                &hashes,
626                &mut results,
627                true,
628            ),
629            "windsurf" => install_rule(
630                &home.join(".windsurf/rules/nika.md"),
631                WINDSURF_RULES,
632                "Windsurf",
633                "windsurf",
634                &hashes,
635                &mut results,
636                true,
637            ),
638            "roo" => install_rule(
639                &home.join(".roo/rules/nika.md"),
640                ROO_RULES,
641                "Roo Code",
642                "roo",
643                &hashes,
644                &mut results,
645                true,
646            ),
647            _ => {}
648        }
649    }
650
651    // Update machine.toml with new editors list + scan timestamp
652    update_machine_toml_editors(&current);
653    write_last_scan_at();
654}
655
656/// Read `last_scan_at` timestamp from machine.toml.
657fn read_last_scan_at() -> Option<u64> {
658    let content = std::fs::read_to_string(machine_toml_path()).ok()?;
659    for line in content.lines() {
660        let trimmed = line.trim();
661        if let Some(rest) = trimmed.strip_prefix("last_scan_at") {
662            let rest = rest.trim().strip_prefix('=').unwrap_or("").trim();
663            return rest.trim_matches('"').parse().ok();
664        }
665    }
666    None
667}
668
669/// Write `last_scan_at` timestamp to machine.toml.
670fn write_last_scan_at() {
671    let marker_path = machine_toml_path();
672    let now = std::time::SystemTime::now()
673        .duration_since(std::time::UNIX_EPOCH)
674        .unwrap_or_default()
675        .as_secs();
676
677    let content = std::fs::read_to_string(&marker_path).unwrap_or_default();
678    let new_line = format!("last_scan_at = \"{}\"", now);
679
680    // Replace existing last_scan_at or append
681    if content.contains("last_scan_at") {
682        let updated: String = content
683            .lines()
684            .map(|l| {
685                if l.trim().starts_with("last_scan_at") {
686                    new_line.as_str()
687                } else {
688                    l
689                }
690            })
691            .collect::<Vec<_>>()
692            .join("\n");
693        std::fs::write(&marker_path, format!("{}\n", updated)).ok();
694    } else {
695        std::fs::write(&marker_path, format!("{}{}\n", content, new_line)).ok();
696    }
697}
698
699/// Read the editors list from machine.toml.
700fn read_stored_editors() -> Vec<String> {
701    let marker = machine_toml_path();
702    let content = match std::fs::read_to_string(&marker) {
703        Ok(c) => c,
704        Err(_) => return Vec::new(),
705    };
706
707    for line in content.lines() {
708        let trimmed = line.trim();
709        if let Some(rest) = trimmed.strip_prefix("editors") {
710            let rest = rest.trim().strip_prefix('=').unwrap_or("").trim();
711            // Parse TOML array: ["vscode", "claude", "cursor"]
712            let inner = rest.trim_start_matches('[').trim_end_matches(']');
713            return inner
714                .split(',')
715                .map(|s| s.trim().trim_matches('"').to_string())
716                .filter(|s| !s.is_empty())
717                .collect();
718        }
719    }
720
721    Vec::new()
722}
723
724/// Update only the editors field in machine.toml, preserving version and
725/// setup_at.
726fn update_machine_toml_editors(editors: &[&str]) {
727    let marker_path = machine_toml_path();
728    let content = match std::fs::read_to_string(&marker_path) {
729        Ok(c) => c,
730        Err(_) => return,
731    };
732
733    let editors_toml: Vec<String> = editors.iter().map(|e| format!("\"{}\"", e)).collect();
734    let new_editors_line = format!("editors = [{}]", editors_toml.join(", "));
735
736    let mut updated = String::new();
737    let mut found = false;
738    for line in content.lines() {
739        if line.trim().starts_with("editors") {
740            updated.push_str(&new_editors_line);
741            found = true;
742        } else {
743            updated.push_str(line);
744        }
745        updated.push('\n');
746    }
747    if !found {
748        updated.push_str(&new_editors_line);
749        updated.push('\n');
750    }
751
752    std::fs::write(&marker_path, updated).ok();
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    #[test]
760    fn machine_toml_path_is_in_home() {
761        let path = machine_toml_path();
762        assert!(path.to_string_lossy().contains(".nika"));
763        assert!(path.to_string_lossy().ends_with("machine.toml"));
764    }
765
766    /// All rule constants must use {{with.item}} not bare {{item}} in code
767    /// examples (the "Wrong" column in mistake tables is exempt).
768    #[test]
769    fn all_rules_use_with_item_not_bare_item() {
770        let rules: &[(&str, &str)] = &[
771            ("CLAUDE_RULES_CONTENT", CLAUDE_RULES_CONTENT),
772            ("CURSOR_NIKA_RULES", CURSOR_NIKA_RULES),
773            ("COPILOT_RULES", COPILOT_RULES),
774            ("WINDSURF_RULES", WINDSURF_RULES),
775            ("ROO_RULES", ROO_RULES),
776        ];
777        for (name, content) in rules {
778            for (i, line) in content.lines().enumerate() {
779                let trimmed = line.trim();
780                if trimmed.contains("{{item}}")
781                    && !trimmed.starts_with('|')
782                    && !trimmed.contains("Wrong")
783                {
784                    panic!(
785                        "{} line {} has bare {{{{item}}}} outside mistakes table: {}",
786                        name,
787                        i + 1,
788                        trimmed
789                    );
790                }
791            }
792        }
793    }
794
795    /// All rule constants must reference schema @0.12.
796    #[test]
797    fn all_rules_reference_current_schema() {
798        let rules: &[(&str, &str)] = &[
799            ("CLAUDE_RULES_CONTENT", CLAUDE_RULES_CONTENT),
800            ("CURSOR_NIKA_RULES", CURSOR_NIKA_RULES),
801            ("COPILOT_RULES", COPILOT_RULES),
802            ("WINDSURF_RULES", WINDSURF_RULES),
803            ("ROO_RULES", ROO_RULES),
804        ];
805        for (name, content) in rules {
806            assert!(content.contains("@0.12"), "{} missing schema @0.12", name);
807        }
808    }
809
810    /// No rule constants should reference nonexistent models.
811    #[test]
812    fn all_rules_no_nonexistent_models() {
813        let rules: &[(&str, &str)] = &[
814            ("CLAUDE_RULES_CONTENT", CLAUDE_RULES_CONTENT),
815            ("CURSOR_NIKA_RULES", CURSOR_NIKA_RULES),
816            ("COPILOT_RULES", COPILOT_RULES),
817            ("WINDSURF_RULES", WINDSURF_RULES),
818            ("ROO_RULES", ROO_RULES),
819        ];
820        for (name, content) in rules {
821            assert!(
822                !content.contains("grok-4"),
823                "{} references nonexistent model grok-4",
824                name
825            );
826        }
827    }
828
829    /// detect_editors returns a Vec (may be empty in CI, but must not panic).
830    #[test]
831    fn detect_editors_does_not_panic() {
832        let editors = detect_editors();
833        // Just ensure it returns without panicking; contents depend on machine
834        assert!(editors.len() <= 10, "unexpectedly many editors detected");
835    }
836
837    /// read_stored_editors parses a TOML editors array correctly.
838    #[test]
839    fn read_stored_editors_parses_toml_array() {
840        let tmpdir = tempfile::tempdir().unwrap();
841        let marker = tmpdir.path().join("machine.toml");
842        std::fs::write(
843            &marker,
844            "[machine]\nversion = \"0.41.3\"\neditors = [\"vscode\", \"claude\", \"cursor\"]\n",
845        )
846        .unwrap();
847
848        // read_stored_editors uses machine_toml_path() which reads from ~/.nika/,
849        // so we test the parsing logic directly
850        let content = std::fs::read_to_string(&marker).unwrap();
851        let mut parsed = Vec::new();
852        for line in content.lines() {
853            let trimmed = line.trim();
854            if let Some(rest) = trimmed.strip_prefix("editors") {
855                let rest = rest.trim().strip_prefix('=').unwrap_or("").trim();
856                let inner = rest.trim_start_matches('[').trim_end_matches(']');
857                parsed = inner
858                    .split(',')
859                    .map(|s| s.trim().trim_matches('"').to_string())
860                    .filter(|s| !s.is_empty())
861                    .collect();
862            }
863        }
864        assert_eq!(parsed, vec!["vscode", "claude", "cursor"]);
865    }
866
867    /// Cursor rules .mdc must have valid frontmatter.
868    #[test]
869    fn cursor_rules_have_mdc_frontmatter() {
870        assert!(
871            CURSOR_NIKA_RULES.starts_with("---\n"),
872            "CURSOR_NIKA_RULES must start with YAML frontmatter"
873        );
874        assert!(
875            CURSOR_NIKA_RULES.contains("globs:"),
876            "CURSOR_NIKA_RULES must have globs: in frontmatter"
877        );
878        assert!(
879            CURSOR_NIKA_RULES.contains("alwaysApply:"),
880            "CURSOR_NIKA_RULES must have alwaysApply: in frontmatter"
881        );
882    }
883
884    /// Copilot rules must have applyTo frontmatter.
885    #[test]
886    fn copilot_rules_have_apply_to() {
887        assert!(
888            COPILOT_RULES.contains("applyTo:"),
889            "COPILOT_RULES must have applyTo: frontmatter"
890        );
891    }
892
893    /// Windsurf rules must have trigger frontmatter.
894    #[test]
895    fn windsurf_rules_have_trigger() {
896        assert!(
897            WINDSURF_RULES.contains("trigger:"),
898            "WINDSURF_RULES must have trigger: frontmatter"
899        );
900    }
901
902    /// install_rule with silent=true writes to disk.
903    #[test]
904    fn install_rule_silent_writes_file() {
905        let tmpdir = tempfile::tempdir().unwrap();
906        let path = tmpdir.path().join("sub/dir/rule.md");
907        let hashes = HashMap::new();
908        let mut results = Vec::new();
909        install_rule(
910            &path,
911            "# test rule\n",
912            "test",
913            "test",
914            &hashes,
915            &mut results,
916            true,
917        );
918        assert!(path.exists());
919        assert_eq!(std::fs::read_to_string(&path).unwrap(), "# test rule\n");
920    }
921}