diff --git a/src/hooks/init/antigravity.rs b/src/hooks/init/antigravity.rs index ee1ed1209f..cb406733c5 100644 --- a/src/hooks/init/antigravity.rs +++ b/src/hooks/init/antigravity.rs @@ -34,11 +34,184 @@ pub const ANTIGRAVITY_HOOKS_JSON: &str = r#"{ /// plugin is enabled. const ANTIGRAVITY_RULES_FILE: &str = "AGENTS.md"; +pub const LEGACY_RULES_REL_PATH: &str = ".agents/rules/antigravity-rtk-rules.md"; + +const LEGACY_ANTIGRAVITY_RULES_V1: &str = r#"# RTK - Rust Token Killer (Google Antigravity) + +**Usage**: Token-optimized CLI proxy for shell commands. + +## Rule + +Always prefix shell commands with `rtk` to minimize token consumption. + +Examples: + +```bash +rtk git status +rtk cargo test +rtk ls src/ +rtk grep "pattern" src/ +rtk find "*.rs" . +rtk docker ps +rtk gh pr list +``` + +## Meta Commands + +```bash +rtk gain # Show token savings +rtk gain --history # Command history with savings +rtk discover # Find missed RTK opportunities +rtk proxy # Run raw (no filtering, for debugging) +``` + +## Why + +RTK filters and compresses command output before it reaches the LLM context, saving 60-90% tokens on common operations. Always use `rtk ` instead of raw commands. +"#; + +#[derive(Debug, PartialEq, Eq)] +pub enum LegacyRulesAction { + RemovedFile(PathBuf), + StrippedSuffix(PathBuf), +} + +/// Detect and migrate legacy `.agents/rules/antigravity-rtk-rules.md` files written +/// by earlier RTK releases. +/// +/// - If the file content is solely an awareness text an earlier release wrote, remove it. +/// - If the file has that awareness text appended after user content following a blank line, +/// remove only the RTK suffix, keeping the user content byte-identical. +/// - Leave any other file at that path untouched and print nothing. +/// - In dry-run mode, report what would be removed/stripped and write nothing. +pub fn migrate_legacy_rules_at( + workspace_root: &Path, + ctx: InitContext, +) -> Result> { + let legacy_file = workspace_root.join(LEGACY_RULES_REL_PATH); + if !legacy_file.is_file() { + return Ok(None); + } + + let raw = match fs::read_to_string(&legacy_file) { + Ok(c) => c, + Err(_) => return Ok(None), + }; + + let normalized = raw.replace("\r\n", "\n"); + let trimmed = normalized.trim(); + + let known_texts = [ + RTK_AWARENESS_FULL, + RTK_AWARENESS_HIGH, + RTK_AWARENESS_DEFAULT, + LEGACY_ANTIGRAVITY_RULES_V1, + ]; + + // 1. Exact match: file content is solely an awareness text an earlier release wrote + for known in &known_texts { + let known_norm = known.replace("\r\n", "\n"); + if trimmed == known_norm.trim() { + if ctx.dry_run { + println!( + "[dry-run] would remove legacy rules file: {}", + legacy_file.display() + ); + } else { + // nosemgrep: filesystem-deletion -- legacy migration removes only RTK's legacy awareness rules file. + fs::remove_file(&legacy_file).with_context(|| { + format!( + "Failed to remove legacy Antigravity rules file: {}", + legacy_file.display() + ) + })?; + if ctx.verbose > 0 { + eprintln!("Removed legacy rules file: {}", legacy_file.display()); + } + } + return Ok(Some(LegacyRulesAction::RemovedFile(legacy_file))); + } + } + + // 2. Suffix removal: awareness text follows user content after a blank line + for known in &known_texts { + let known_norm = known.replace("\r\n", "\n"); + let target = format!("\n\n{}", known_norm.trim()); + if trimmed.ends_with(&target) { + let prefix_len = trimmed.len() - target.len(); + let remaining_trimmed = &trimmed[..prefix_len]; + + if remaining_trimmed.trim().is_empty() { + if ctx.dry_run { + println!( + "[dry-run] would remove legacy rules file: {}", + legacy_file.display() + ); + } else { + // nosemgrep: filesystem-deletion -- legacy migration removes only RTK's legacy awareness rules file. + fs::remove_file(&legacy_file).with_context(|| { + format!( + "Failed to remove legacy Antigravity rules file: {}", + legacy_file.display() + ) + })?; + if ctx.verbose > 0 { + eprintln!("Removed legacy rules file: {}", legacy_file.display()); + } + } + return Ok(Some(LegacyRulesAction::RemovedFile(legacy_file))); + } + + let target_trimmed = known_norm.trim(); + let crlf_suffix = format!("\r\n\r\n{}", target_trimmed.replace('\n', "\r\n")); + let lf_suffix = format!("\n\n{}", target_trimmed); + + let remaining_bytes = if raw.trim_end().ends_with(&crlf_suffix) { + let cut_idx = raw.trim_end().len() - crlf_suffix.len(); + &raw[..cut_idx] + } else if raw.trim_end().ends_with(&lf_suffix) { + let cut_idx = raw.trim_end().len() - lf_suffix.len(); + &raw[..cut_idx] + } else { + remaining_trimmed + }; + + if ctx.dry_run { + println!( + "[dry-run] would remove legacy awareness text from {}", + legacy_file.display() + ); + } else { + atomic_write(&legacy_file, remaining_bytes).with_context(|| { + format!( + "Failed to update legacy Antigravity rules file: {}", + legacy_file.display() + ) + })?; + if ctx.verbose > 0 { + eprintln!( + "Removed legacy awareness text from: {}", + legacy_file.display() + ); + } + } + return Ok(Some(LegacyRulesAction::StrippedSuffix(legacy_file))); + } + } + + // 3. Foreign / unrelated file: leave alone and say nothing + Ok(None) +} + pub fn run_antigravity_mode(global: bool, ctx: InitContext) -> Result<()> { if global { let home = user_dirs::home().context("Could not determine user home directory")?; let base_dir = home.join(".gemini/config"); - run_antigravity_mode_at(&base_dir, true, ctx) + run_antigravity_mode_at(&base_dir, true, ctx)?; + if let Ok(cwd) = user_dirs::current_dir() { + let _ = migrate_legacy_rules_at(&cwd, ctx)?; + } + Ok(()) } else { let cwd = user_dirs::current_dir().context("Failed to read current directory")?; run_antigravity_mode_at(&cwd, false, ctx) @@ -55,6 +228,12 @@ pub fn run_antigravity_mode_at(base_dir: &Path, global: bool, ctx: InitContext) base_dir.join(".agents/plugins/rtk") }; + let legacy_action = if !global { + migrate_legacy_rules_at(base_dir, ctx)? + } else { + None + }; + let plugin_json_path = plugin_dir.join("plugin.json"); let hooks_json_path = plugin_dir.join("hooks.json"); // Rules under `rules/` apply whenever the plugin is active, as plain markdown @@ -114,6 +293,15 @@ pub fn run_antigravity_mode_at(base_dir: &Path, global: bool, ctx: InitContext) " Rules: rules/{ANTIGRAVITY_RULES_FILE} (awareness level: {})", ctx.awareness ); + match legacy_action { + Some(LegacyRulesAction::RemovedFile(ref p)) => { + println!(" Legacy: removed {}", p.display()); + } + Some(LegacyRulesAction::StrippedSuffix(ref p)) => { + println!(" Legacy: removed awareness text from {}", p.display()); + } + None => {} + } println!(" Restart Antigravity to load the plugin. Test with: git status"); println!( "\n Note: Antigravity checks permissions after hooks rewrite a command.\n \ @@ -133,7 +321,21 @@ pub fn uninstall_antigravity_mode(global: bool, ctx: InitContext) -> Result<()> } else { user_dirs::current_dir().context("Failed to read current directory")? }; - let removed = uninstall_antigravity_mode_at(&base_dir, global, ctx)?; + let mut removed = uninstall_antigravity_mode_at(&base_dir, global, ctx)?; + + if global + && let Ok(cwd) = user_dirs::current_dir() + && let Some(action) = migrate_legacy_rules_at(&cwd, ctx)? + { + match action { + LegacyRulesAction::RemovedFile(p) => { + removed.push(format!("legacy rules file: {}", p.display())); + } + LegacyRulesAction::StrippedSuffix(p) => { + removed.push(format!("legacy awareness text in: {}", p.display())); + } + } + } if removed.is_empty() { println!("RTK Antigravity support was not installed (nothing to remove)"); @@ -191,6 +393,17 @@ pub fn uninstall_antigravity_mode_at( removed.push(format!("Antigravity plugin: {}", plugin_dir.display())); } + if !global && let Some(action) = migrate_legacy_rules_at(base_dir, ctx)? { + match action { + LegacyRulesAction::RemovedFile(p) => { + removed.push(format!("legacy rules file: {}", p.display())); + } + LegacyRulesAction::StrippedSuffix(p) => { + removed.push(format!("legacy awareness text in: {}", p.display())); + } + } + } + Ok(removed) } @@ -337,4 +550,103 @@ mod tests { let plugin_dir = temp.path().join(".agents/plugins/rtk"); assert!(!plugin_dir.exists(), "Plugin dir should be removed"); } + + #[test] + fn test_migrate_legacy_rules_exact_match_removed() { + let temp = TempDir::new().unwrap(); + let legacy_file = temp.path().join(LEGACY_RULES_REL_PATH); + fs::create_dir_all(legacy_file.parent().unwrap()).unwrap(); + fs::write(&legacy_file, RTK_AWARENESS_FULL).unwrap(); + + let action = migrate_legacy_rules_at(temp.path(), InitContext::default()).unwrap(); + assert_eq!( + action, + Some(LegacyRulesAction::RemovedFile(legacy_file.clone())) + ); + assert!(!legacy_file.exists()); + } + + #[test] + fn test_migrate_legacy_rules_exact_match_v1_removed() { + let temp = TempDir::new().unwrap(); + let legacy_file = temp.path().join(LEGACY_RULES_REL_PATH); + fs::create_dir_all(legacy_file.parent().unwrap()).unwrap(); + fs::write(&legacy_file, LEGACY_ANTIGRAVITY_RULES_V1).unwrap(); + + let action = migrate_legacy_rules_at(temp.path(), InitContext::default()).unwrap(); + assert_eq!( + action, + Some(LegacyRulesAction::RemovedFile(legacy_file.clone())) + ); + assert!(!legacy_file.exists()); + } + + #[test] + fn test_migrate_legacy_rules_suffix_stripped() { + let temp = TempDir::new().unwrap(); + let legacy_file = temp.path().join(LEGACY_RULES_REL_PATH); + fs::create_dir_all(legacy_file.parent().unwrap()).unwrap(); + let user_content = "# Project Coding Standards\n\nAlways write tests."; + let full_content = format!("{user_content}\n\n{}", RTK_AWARENESS_FULL); + fs::write(&legacy_file, &full_content).unwrap(); + + let action = migrate_legacy_rules_at(temp.path(), InitContext::default()).unwrap(); + assert_eq!( + action, + Some(LegacyRulesAction::StrippedSuffix(legacy_file.clone())) + ); + assert!(legacy_file.exists()); + let updated = fs::read_to_string(&legacy_file).unwrap(); + assert_eq!(updated, user_content); + } + + #[test] + fn test_migrate_legacy_rules_foreign_untouched() { + let temp = TempDir::new().unwrap(); + let legacy_file = temp.path().join(LEGACY_RULES_REL_PATH); + fs::create_dir_all(legacy_file.parent().unwrap()).unwrap(); + let foreign_content = "Prefer RTK Query for data fetching; do not add axios."; + fs::write(&legacy_file, foreign_content).unwrap(); + + let action = migrate_legacy_rules_at(temp.path(), InitContext::default()).unwrap(); + assert_eq!(action, None); + assert!(legacy_file.exists()); + assert_eq!(fs::read_to_string(&legacy_file).unwrap(), foreign_content); + } + + #[test] + fn test_migrate_legacy_rules_dry_run_leaves_file() { + let temp = TempDir::new().unwrap(); + let legacy_file = temp.path().join(LEGACY_RULES_REL_PATH); + fs::create_dir_all(legacy_file.parent().unwrap()).unwrap(); + fs::write(&legacy_file, RTK_AWARENESS_FULL).unwrap(); + + let action = migrate_legacy_rules_at( + temp.path(), + InitContext { + dry_run: true, + ..InitContext::default() + }, + ) + .unwrap(); + assert_eq!( + action, + Some(LegacyRulesAction::RemovedFile(legacy_file.clone())) + ); + assert!(legacy_file.exists(), "Dry run must not delete file"); + } + + #[test] + fn test_antigravity_uninstall_with_only_legacy_rules_removes_them() { + let temp = TempDir::new().unwrap(); + let legacy_file = temp.path().join(LEGACY_RULES_REL_PATH); + fs::create_dir_all(legacy_file.parent().unwrap()).unwrap(); + fs::write(&legacy_file, RTK_AWARENESS_FULL).unwrap(); + + let removed = + uninstall_antigravity_mode_at(temp.path(), false, InitContext::default()).unwrap(); + assert_eq!(removed.len(), 1); + assert!(removed[0].contains("legacy rules file")); + assert!(!legacy_file.exists()); + } } diff --git a/tests/antigravity_hook_test.rs b/tests/antigravity_hook_test.rs index 98713e60fd..e50ea29ee0 100644 --- a/tests/antigravity_hook_test.rs +++ b/tests/antigravity_hook_test.rs @@ -1,4 +1,5 @@ use serde_json::{Value, json}; +use std::fs; use std::io::Write; use std::path::Path; use std::process::{Output, Stdio}; @@ -240,3 +241,160 @@ fn antigravity_dry_runs_change_nothing() { "uninstall dry run must leave the plugin in place" ); } + +const LEGACY_AWARENESS: &str = include_str!("../hooks/rtk-awareness-full.md"); +const LEGACY_RULES_PATH: &str = ".agents/rules/antigravity-rtk-rules.md"; + +#[test] +fn antigravity_init_removes_exact_match_legacy_rules() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let legacy_file = project.path().join(LEGACY_RULES_PATH); + fs::create_dir_all(legacy_file.parent().unwrap()).unwrap(); + fs::write(&legacy_file, LEGACY_AWARENESS).unwrap(); + + let output = run_init(project.path(), home.path(), &["--agent", "antigravity"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Legacy: removed"), "{stdout}"); + assert!(!legacy_file.exists(), "Legacy file must be removed"); + assert!( + project + .path() + .join(".agents/plugins/rtk/plugin.json") + .is_file(), + "Plugin must be installed" + ); +} + +#[test] +fn antigravity_init_strips_suffix_preserving_user_content() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let legacy_file = project.path().join(LEGACY_RULES_PATH); + fs::create_dir_all(legacy_file.parent().unwrap()).unwrap(); + let user_content = "# Project Standards\n\nAlways write tests."; + fs::write( + &legacy_file, + format!("{user_content}\n\n{LEGACY_AWARENESS}"), + ) + .unwrap(); + + let output = run_init(project.path(), home.path(), &["--agent", "antigravity"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Legacy: removed awareness text from"), + "{stdout}" + ); + assert!(legacy_file.exists(), "File with user content must remain"); + let remaining = fs::read_to_string(&legacy_file).unwrap(); + assert_eq!(remaining, user_content); +} + +#[test] +fn antigravity_init_leaves_foreign_rules_untouched() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let legacy_file = project.path().join(LEGACY_RULES_PATH); + fs::create_dir_all(legacy_file.parent().unwrap()).unwrap(); + let foreign = "Prefer RTK Query for data fetching; do not add axios."; + fs::write(&legacy_file, foreign).unwrap(); + + let output = run_init(project.path(), home.path(), &["--agent", "antigravity"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(!stdout.contains("Legacy:"), "{stdout}"); + assert_eq!(fs::read_to_string(&legacy_file).unwrap(), foreign); +} + +#[test] +fn antigravity_uninstall_removes_legacy_rules() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let legacy_file = project.path().join(LEGACY_RULES_PATH); + fs::create_dir_all(legacy_file.parent().unwrap()).unwrap(); + fs::write(&legacy_file, LEGACY_AWARENESS).unwrap(); + + let output = run_init( + project.path(), + home.path(), + &["--agent", "antigravity", "--uninstall"], + ); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("RTK uninstalled for Google Antigravity:"), + "{stdout}" + ); + assert!(stdout.contains("legacy rules file:"), "{stdout}"); + assert!(!legacy_file.exists()); +} + +#[test] +fn antigravity_dry_run_leaves_legacy_rules_in_place() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let legacy_file = project.path().join(LEGACY_RULES_PATH); + fs::create_dir_all(legacy_file.parent().unwrap()).unwrap(); + fs::write(&legacy_file, LEGACY_AWARENESS).unwrap(); + + let output = run_init( + project.path(), + home.path(), + &["--agent", "antigravity", "--dry-run"], + ); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("[dry-run] would remove legacy rules file:"), + "{stdout}" + ); + assert!(stdout.contains("[dry-run] Nothing written."), "{stdout}"); + assert!(legacy_file.exists(), "Dry-run must not remove legacy file"); + + let output = run_init( + project.path(), + home.path(), + &["--agent", "antigravity", "--uninstall", "--dry-run"], + ); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("[dry-run] would uninstall RTK for Google Antigravity:"), + "{stdout}" + ); + assert!(stdout.contains("legacy rules file:"), "{stdout}"); + assert!(stdout.contains("[dry-run] Nothing written."), "{stdout}"); + assert!( + legacy_file.exists(), + "Uninstall dry-run must not remove legacy file" + ); +} + +#[test] +fn antigravity_global_init_cleans_workspace_legacy_rules() { + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + let legacy_file = project.path().join(LEGACY_RULES_PATH); + fs::create_dir_all(legacy_file.parent().unwrap()).unwrap(); + fs::write(&legacy_file, LEGACY_AWARENESS).unwrap(); + + let output = run_init( + project.path(), + home.path(), + &["-g", "--agent", "antigravity"], + ); + assert!(output.status.success()); + assert!( + !legacy_file.exists(), + "Global init in workspace must remove legacy rules" + ); + #[cfg(unix)] + assert!( + home.path() + .join(".gemini/config/plugins/rtk/plugin.json") + .is_file(), + "Global plugin must be installed" + ); +}