|
| 1 | +//! Wire (and unwire) the Burnwall ribbon into Claude Code's |
| 2 | +//! `~/.claude/settings.json` `statusLine` block. |
| 3 | +//! |
| 4 | +//! Claude Code reads a custom status line from a `statusLine` object in its |
| 5 | +//! settings file. `burnwall statusline` renders that line, but nothing wired |
| 6 | +//! it up for the user — they had to hand-edit JSON. `init --apply` now calls |
| 7 | +//! [`install`]; `uninstall` calls [`remove`]. |
| 8 | +//! |
| 9 | +//! ## Principles |
| 10 | +//! |
| 11 | +//! - **Idempotent merge.** We parse the existing settings, set *only* the |
| 12 | +//! `statusLine` key, and write everything else back untouched. Re-running is |
| 13 | +//! a no-op. |
| 14 | +//! - **Never clobber a foreign status line.** If the user already points |
| 15 | +//! `statusLine` at something that isn't ours, we leave it alone and report |
| 16 | +//! it — security software doesn't silently overwrite your config. |
| 17 | +//! - **PATH-resolved command.** We write `"burnwall statusline"`, not an |
| 18 | +//! absolute path, so the wiring survives a reinstall to a different dir |
| 19 | +//! (the installer puts `burnwall` on PATH). |
| 20 | +
|
| 21 | +use std::path::{Path, PathBuf}; |
| 22 | + |
| 23 | +use anyhow::{Context, Result}; |
| 24 | + |
| 25 | +/// The command we write into `statusLine.command`. PATH-resolved on purpose — |
| 26 | +/// see the module docs. |
| 27 | +pub const STATUSLINE_COMMAND: &str = "burnwall statusline"; |
| 28 | + |
| 29 | +/// `~/.claude/settings.json`. Same location on every OS. |
| 30 | +pub fn settings_path() -> Option<PathBuf> { |
| 31 | + dirs::home_dir().map(|h| h.join(".claude").join("settings.json")) |
| 32 | +} |
| 33 | + |
| 34 | +/// Our canonical `statusLine` value. |
| 35 | +fn our_statusline() -> serde_json::Value { |
| 36 | + serde_json::json!({ |
| 37 | + "type": "command", |
| 38 | + "command": STATUSLINE_COMMAND, |
| 39 | + "padding": 0 |
| 40 | + }) |
| 41 | +} |
| 42 | + |
| 43 | +/// Does an existing `statusLine` value look like ours? True if its `command` |
| 44 | +/// mentions both `burnwall` and `statusline` — this matches the PATH form |
| 45 | +/// (`burnwall statusline`) and any absolute-path form |
| 46 | +/// (`…/burnwall.exe statusline`) a user may have hand-written, so `remove` |
| 47 | +/// cleans those up too. |
| 48 | +fn is_ours(statusline: &serde_json::Value) -> bool { |
| 49 | + statusline |
| 50 | + .get("command") |
| 51 | + .and_then(|c| c.as_str()) |
| 52 | + .map(|c| { |
| 53 | + let lc = c.to_lowercase(); |
| 54 | + lc.contains("burnwall") && lc.contains("statusline") |
| 55 | + }) |
| 56 | + .unwrap_or(false) |
| 57 | +} |
| 58 | + |
| 59 | +/// Outcome of [`install`], so the caller can print an honest status line. |
| 60 | +#[derive(Debug, PartialEq, Eq)] |
| 61 | +pub enum InstallOutcome { |
| 62 | + /// We added (or refreshed) the Burnwall status line. |
| 63 | + Wrote, |
| 64 | + /// A Burnwall status line identical to ours was already present. |
| 65 | + AlreadyOurs, |
| 66 | + /// A *different* `statusLine` is configured — we left it untouched. The |
| 67 | + /// string is its `command`, for the message. |
| 68 | + ForeignPresent(String), |
| 69 | +} |
| 70 | + |
| 71 | +/// Parse `settings.json` into an object, tolerating a missing file (→ empty |
| 72 | +/// object) but not malformed JSON (we won't blindly overwrite a file we can't |
| 73 | +/// understand). |
| 74 | +fn read_object(path: &Path) -> Result<serde_json::Map<String, serde_json::Value>> { |
| 75 | + match std::fs::read_to_string(path) { |
| 76 | + Ok(s) if s.trim().is_empty() => Ok(serde_json::Map::new()), |
| 77 | + Ok(s) => { |
| 78 | + let v: serde_json::Value = serde_json::from_str(&s) |
| 79 | + .with_context(|| format!("parsing {} (not valid JSON)", path.display()))?; |
| 80 | + match v { |
| 81 | + serde_json::Value::Object(m) => Ok(m), |
| 82 | + _ => anyhow::bail!("{} is not a JSON object", path.display()), |
| 83 | + } |
| 84 | + } |
| 85 | + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::Map::new()), |
| 86 | + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +/// Pretty-write the object back as `settings.json`, creating `~/.claude` if |
| 91 | +/// needed. Trailing newline so the file is POSIX-tidy. |
| 92 | +fn write_object(path: &Path, obj: &serde_json::Map<String, serde_json::Value>) -> Result<()> { |
| 93 | + if let Some(parent) = path.parent() { |
| 94 | + std::fs::create_dir_all(parent) |
| 95 | + .with_context(|| format!("creating {}", parent.display()))?; |
| 96 | + } |
| 97 | + let mut s = serde_json::to_string_pretty(&serde_json::Value::Object(obj.clone()))?; |
| 98 | + s.push('\n'); |
| 99 | + std::fs::write(path, s).with_context(|| format!("writing {}", path.display()))?; |
| 100 | + Ok(()) |
| 101 | +} |
| 102 | + |
| 103 | +/// Merge the Burnwall `statusLine` into `path`. Idempotent; never clobbers a |
| 104 | +/// foreign status line. |
| 105 | +pub fn install(path: &Path) -> Result<InstallOutcome> { |
| 106 | + let mut obj = read_object(path)?; |
| 107 | + if let Some(existing) = obj.get("statusLine") { |
| 108 | + if is_ours(existing) { |
| 109 | + // Refresh only if the value drifted from canonical (e.g. an old |
| 110 | + // absolute-path form) — otherwise it's a true no-op. |
| 111 | + if existing == &our_statusline() { |
| 112 | + return Ok(InstallOutcome::AlreadyOurs); |
| 113 | + } |
| 114 | + } else { |
| 115 | + let cmd = existing |
| 116 | + .get("command") |
| 117 | + .and_then(|c| c.as_str()) |
| 118 | + .unwrap_or("<non-command status line>") |
| 119 | + .to_string(); |
| 120 | + return Ok(InstallOutcome::ForeignPresent(cmd)); |
| 121 | + } |
| 122 | + } |
| 123 | + obj.insert("statusLine".to_string(), our_statusline()); |
| 124 | + write_object(path, &obj)?; |
| 125 | + Ok(InstallOutcome::Wrote) |
| 126 | +} |
| 127 | + |
| 128 | +/// Remove the Burnwall `statusLine` from `path`. Returns `true` if we removed |
| 129 | +/// it, `false` if there was nothing of ours to remove (missing file, no |
| 130 | +/// `statusLine`, or a foreign one we won't touch). |
| 131 | +pub fn remove(path: &Path) -> Result<bool> { |
| 132 | + let mut obj = match std::fs::read_to_string(path) { |
| 133 | + Ok(s) if s.trim().is_empty() => return Ok(false), |
| 134 | + Ok(s) => match serde_json::from_str::<serde_json::Value>(&s) { |
| 135 | + Ok(serde_json::Value::Object(m)) => m, |
| 136 | + // Unparseable / non-object: leave it alone. |
| 137 | + _ => return Ok(false), |
| 138 | + }, |
| 139 | + Err(_) => return Ok(false), |
| 140 | + }; |
| 141 | + match obj.get("statusLine") { |
| 142 | + Some(v) if is_ours(v) => { |
| 143 | + obj.remove("statusLine"); |
| 144 | + write_object(path, &obj)?; |
| 145 | + Ok(true) |
| 146 | + } |
| 147 | + _ => Ok(false), |
| 148 | + } |
| 149 | +} |
| 150 | + |
| 151 | +#[cfg(test)] |
| 152 | +mod tests { |
| 153 | + use super::*; |
| 154 | + |
| 155 | + fn tmp() -> (tempfile::TempDir, PathBuf) { |
| 156 | + let dir = tempfile::tempdir().unwrap(); |
| 157 | + let path = dir.path().join("settings.json"); |
| 158 | + (dir, path) |
| 159 | + } |
| 160 | + |
| 161 | + #[test] |
| 162 | + fn install_into_missing_file_creates_it() { |
| 163 | + let (_d, path) = tmp(); |
| 164 | + assert_eq!(install(&path).unwrap(), InstallOutcome::Wrote); |
| 165 | + let v: serde_json::Value = |
| 166 | + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); |
| 167 | + assert_eq!(v["statusLine"]["command"], STATUSLINE_COMMAND); |
| 168 | + assert_eq!(v["statusLine"]["type"], "command"); |
| 169 | + } |
| 170 | + |
| 171 | + #[test] |
| 172 | + fn install_preserves_existing_keys() { |
| 173 | + let (_d, path) = tmp(); |
| 174 | + std::fs::write( |
| 175 | + &path, |
| 176 | + r#"{"theme":"dark","permissions":{"allow":["Bash(*)"]}}"#, |
| 177 | + ) |
| 178 | + .unwrap(); |
| 179 | + assert_eq!(install(&path).unwrap(), InstallOutcome::Wrote); |
| 180 | + let v: serde_json::Value = |
| 181 | + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); |
| 182 | + assert_eq!(v["theme"], "dark"); |
| 183 | + assert_eq!(v["permissions"]["allow"][0], "Bash(*)"); |
| 184 | + assert_eq!(v["statusLine"]["command"], STATUSLINE_COMMAND); |
| 185 | + } |
| 186 | + |
| 187 | + #[test] |
| 188 | + fn install_is_idempotent() { |
| 189 | + let (_d, path) = tmp(); |
| 190 | + assert_eq!(install(&path).unwrap(), InstallOutcome::Wrote); |
| 191 | + assert_eq!(install(&path).unwrap(), InstallOutcome::AlreadyOurs); |
| 192 | + } |
| 193 | + |
| 194 | + #[test] |
| 195 | + fn install_refreshes_absolute_path_form() { |
| 196 | + let (_d, path) = tmp(); |
| 197 | + std::fs::write( |
| 198 | + &path, |
| 199 | + r#"{"statusLine":{"type":"command","command":"C:\\x\\burnwall.exe statusline","padding":0}}"#, |
| 200 | + ) |
| 201 | + .unwrap(); |
| 202 | + // Recognized as ours (burnwall + statusline) but drifted → rewritten. |
| 203 | + assert_eq!(install(&path).unwrap(), InstallOutcome::Wrote); |
| 204 | + let v: serde_json::Value = |
| 205 | + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); |
| 206 | + assert_eq!(v["statusLine"]["command"], STATUSLINE_COMMAND); |
| 207 | + } |
| 208 | + |
| 209 | + #[test] |
| 210 | + fn install_will_not_clobber_foreign_statusline() { |
| 211 | + let (_d, path) = tmp(); |
| 212 | + std::fs::write( |
| 213 | + &path, |
| 214 | + r#"{"statusLine":{"type":"command","command":"my-custom-bar.sh"}}"#, |
| 215 | + ) |
| 216 | + .unwrap(); |
| 217 | + match install(&path).unwrap() { |
| 218 | + InstallOutcome::ForeignPresent(cmd) => assert_eq!(cmd, "my-custom-bar.sh"), |
| 219 | + other => panic!("expected ForeignPresent, got {other:?}"), |
| 220 | + } |
| 221 | + // And the foreign value is untouched on disk. |
| 222 | + let v: serde_json::Value = |
| 223 | + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); |
| 224 | + assert_eq!(v["statusLine"]["command"], "my-custom-bar.sh"); |
| 225 | + } |
| 226 | + |
| 227 | + #[test] |
| 228 | + fn install_bails_on_malformed_json() { |
| 229 | + let (_d, path) = tmp(); |
| 230 | + std::fs::write(&path, "{not json").unwrap(); |
| 231 | + assert!(install(&path).is_err()); |
| 232 | + } |
| 233 | + |
| 234 | + #[test] |
| 235 | + fn remove_takes_out_ours_and_keeps_the_rest() { |
| 236 | + let (_d, path) = tmp(); |
| 237 | + std::fs::write(&path, r#"{"theme":"dark"}"#).unwrap(); |
| 238 | + install(&path).unwrap(); |
| 239 | + assert!(remove(&path).unwrap()); |
| 240 | + let v: serde_json::Value = |
| 241 | + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); |
| 242 | + assert!(v.get("statusLine").is_none()); |
| 243 | + assert_eq!(v["theme"], "dark"); |
| 244 | + } |
| 245 | + |
| 246 | + #[test] |
| 247 | + fn remove_leaves_foreign_statusline() { |
| 248 | + let (_d, path) = tmp(); |
| 249 | + std::fs::write( |
| 250 | + &path, |
| 251 | + r#"{"statusLine":{"type":"command","command":"my-custom-bar.sh"}}"#, |
| 252 | + ) |
| 253 | + .unwrap(); |
| 254 | + assert!(!remove(&path).unwrap()); |
| 255 | + let v: serde_json::Value = |
| 256 | + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); |
| 257 | + assert_eq!(v["statusLine"]["command"], "my-custom-bar.sh"); |
| 258 | + } |
| 259 | + |
| 260 | + #[test] |
| 261 | + fn remove_on_missing_file_is_false() { |
| 262 | + let (_d, path) = tmp(); |
| 263 | + assert!(!remove(&path).unwrap()); |
| 264 | + } |
| 265 | +} |
0 commit comments