1use 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
11pub(super) fn detect_editors() -> Vec<&'static str> {
18 let mut editors = Vec::new();
19 let home = dirs::home_dir().unwrap_or_default();
20
21 if which::which("code").is_ok() || check_macos_app("VS Code") {
23 editors.push("vscode");
24 }
25 if which::which("cursor").is_ok() || home.join(".cursor").exists() || check_macos_app("Cursor")
27 {
28 editors.push("cursor");
29 }
30 if which::which("claude").is_ok() || home.join(".claude").exists() {
32 editors.push("claude");
33 }
34 if which::which("windsurf").is_ok() || check_macos_app("Windsurf") {
36 editors.push("windsurf");
37 }
38 if home.join(".roo").exists() {
40 editors.push("roo");
41 }
42 if which::which("gh").is_ok() {
44 editors.push("copilot");
45 }
46
47 editors
48}
49
50pub fn run_machine_setup() -> Vec<SetupResult> {
56 let mut results = Vec::new();
57
58 results.extend(setup_editors());
60
61 results.extend(setup_ai_rules());
63
64 results.push(setup_completions());
66
67 write_marker(&results);
69
70 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 let has_binary = which::which(binary).is_ok() || check_macos_app(name);
96 if !has_binary {
97 continue;
98 }
99
100 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 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
184const CLAUDE_RULES_CONTENT: &str = include_str!("../../rules/claude.md");
186
187const CURSOR_NIKA_RULES: &str = include_str!("../../rules/cursor.mdc");
192
193const COPILOT_RULES: &str = include_str!("../../rules/copilot.md");
195
196const WINDSURF_RULES: &str = include_str!("../../rules/windsurf.md");
198
199const ROO_RULES: &str = include_str!("../../rules/roo.md");
201
202fn 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 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 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 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 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 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 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 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
385fn 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 path.exists() {
407 if let Ok(disk_content) = std::fs::read_to_string(path) {
408 let disk_hash = hash_content(&disk_content);
409
410 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 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 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 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
563const SCAN_COOLDOWN_SECS: u64 = 86_400;
567
568pub fn quick_editor_scan() {
574 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_editors(¤t);
653 write_last_scan_at();
654}
655
656fn 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
669fn 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 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
699fn 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 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
724fn 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 #[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 #[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 #[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 #[test]
831 fn detect_editors_does_not_panic() {
832 let editors = detect_editors();
833 assert!(editors.len() <= 10, "unexpectedly many editors detected");
835 }
836
837 #[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 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 #[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 #[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 #[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 #[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}