| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
use anyhow::{bail, Result}; |
| 13 |
use oxy_core::mcp::{self, arg_str, arg_strings, ServerInfo, Tool, ToolOutput, Tools}; |
| 14 |
use serde_json::{json, Value}; |
| 15 |
use std::path::{Path, PathBuf}; |
| 16 |
use std::process::{Command, Stdio}; |
| 17 |
|
| 18 |
|
| 19 |
const MAX_TEXT: usize = 256 * 1024; |
| 20 |
|
| 21 |
type Build = fn(&Value, bool) -> Result<Vec<String>, String>; |
| 22 |
|
| 23 |
struct Spec { |
| 24 |
name: &'static str, |
| 25 |
description: &'static str, |
| 26 |
schema: fn() -> Value, |
| 27 |
build: Build, |
| 28 |
json: bool, |
| 29 |
read_only: bool, |
| 30 |
destructive: bool, |
| 31 |
} |
| 32 |
|
| 33 |
fn obj(props: Value, required: &[&str]) -> Value { |
| 34 |
json!({ "type": "object", "properties": props, "required": required, "additionalProperties": false }) |
| 35 |
} |
| 36 |
|
| 37 |
fn none() -> Value { |
| 38 |
obj(json!({}), &[]) |
| 39 |
} |
| 40 |
|
| 41 |
|
| 42 |
|
| 43 |
fn opt(flag: &str, value: &str) -> String { |
| 44 |
format!("{flag}={value}") |
| 45 |
} |
| 46 |
|
| 47 |
|
| 48 |
fn positional(out: &mut Vec<String>, values: Vec<String>) { |
| 49 |
if !values.is_empty() { |
| 50 |
out.push("--".into()); |
| 51 |
out.extend(values); |
| 52 |
} |
| 53 |
} |
| 54 |
|
| 55 |
fn required<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> { |
| 56 |
arg_str(args, key) |
| 57 |
.filter(|s| !s.trim().is_empty()) |
| 58 |
.ok_or_else(|| format!("{key} is required")) |
| 59 |
} |
| 60 |
|
| 61 |
|
| 62 |
|
| 63 |
fn paths_or_all(args: &Value) -> Result<Vec<String>, String> { |
| 64 |
let paths = arg_strings(args, "paths")?; |
| 65 |
let all = args.get("all").and_then(Value::as_bool).unwrap_or(false); |
| 66 |
match (paths.is_empty(), all) { |
| 67 |
(false, false) => Ok(paths), |
| 68 |
(true, true) => Ok(vec![]), |
| 69 |
(false, true) => Err("give paths or all: true, not both".into()), |
| 70 |
(true, false) => { |
| 71 |
Err("give the paths to include, or all: true for every changed path".into()) |
| 72 |
} |
| 73 |
} |
| 74 |
} |
| 75 |
|
| 76 |
fn limit(args: &Value, default: u64) -> String { |
| 77 |
args.get("limit") |
| 78 |
.and_then(Value::as_u64) |
| 79 |
.unwrap_or(default) |
| 80 |
.clamp(1, 1000) |
| 81 |
.to_string() |
| 82 |
} |
| 83 |
|
| 84 |
fn specs() -> Vec<Spec> { |
| 85 |
vec![ |
| 86 |
Spec { |
| 87 |
name: "status", |
| 88 |
description: "The working copy: the bookmark, changed paths, and every conflict with its kind and the changes behind it.", |
| 89 |
schema: none, |
| 90 |
build: |_, _| Ok(vec!["status".into(), "--json".into()]), |
| 91 |
json: true, |
| 92 |
read_only: true, |
| 93 |
destructive: false, |
| 94 |
}, |
| 95 |
Spec { |
| 96 |
name: "diff", |
| 97 |
description: "Uncommitted edits in the working copy, or one change's edits when `change` is given (a ChangeId or #hash prefix).", |
| 98 |
schema: || obj(json!({ "change": { "type": "string" } }), &[]), |
| 99 |
build: |a, _| { |
| 100 |
let mut v = vec!["diff".into(), "--json".into()]; |
| 101 |
positional(&mut v, arg_str(a, "change").map(|c| vec![c.to_string()]).unwrap_or_default()); |
| 102 |
Ok(v) |
| 103 |
}, |
| 104 |
json: true, |
| 105 |
read_only: true, |
| 106 |
destructive: false, |
| 107 |
}, |
| 108 |
Spec { |
| 109 |
name: "log", |
| 110 |
description: "Changes, newest first: a revset (`main`, `@`, …) or the current bookmark, optionally only those touching `path`; `limit` defaults to 20.", |
| 111 |
schema: || obj(json!({ "revset": { "type": "string" }, "path": { "type": "string" }, "limit": { "type": "integer", "minimum": 1 } }), &[]), |
| 112 |
build: |a, _| { |
| 113 |
let mut v = vec!["log".into(), "--json".into(), opt("--limit", &limit(a, 20))]; |
| 114 |
if let Some(r) = arg_str(a, "revset") { |
| 115 |
v.push(opt("--revset", r)); |
| 116 |
} |
| 117 |
if let Some(p) = arg_str(a, "path") { |
| 118 |
v.push(opt("--path", p)); |
| 119 |
} |
| 120 |
Ok(v) |
| 121 |
}, |
| 122 |
json: true, |
| 123 |
read_only: true, |
| 124 |
destructive: false, |
| 125 |
}, |
| 126 |
Spec { |
| 127 |
name: "show", |
| 128 |
description: "One change: its message, author, signatures and edits (`stat: true` for files and line counts only). `change` defaults to @, the working copy.", |
| 129 |
schema: || obj(json!({ "change": { "type": "string" }, "stat": { "type": "boolean" } }), &[]), |
| 130 |
build: |a, _| { |
| 131 |
let mut v = vec!["show".into(), "--json".into()]; |
| 132 |
if a.get("stat").and_then(Value::as_bool).unwrap_or(false) { |
| 133 |
v.push("--stat".into()); |
| 134 |
} |
| 135 |
positional(&mut v, vec![arg_str(a, "change").unwrap_or("@").to_string()]); |
| 136 |
Ok(v) |
| 137 |
}, |
| 138 |
json: true, |
| 139 |
read_only: true, |
| 140 |
destructive: false, |
| 141 |
}, |
| 142 |
Spec { |
| 143 |
name: "cat", |
| 144 |
description: "A file's content as a bookmark, tag or change has it (`change`), or as committed on the working copy's base.", |
| 145 |
schema: || obj(json!({ "path": { "type": "string" }, "change": { "type": "string" } }), &["path"]), |
| 146 |
build: |a, _| { |
| 147 |
let mut v = vec!["cat".into()]; |
| 148 |
if let Some(c) = arg_str(a, "change") { |
| 149 |
v.push(opt("--change", c)); |
| 150 |
} |
| 151 |
positional(&mut v, vec![required(a, "path")?.to_string()]); |
| 152 |
Ok(v) |
| 153 |
}, |
| 154 |
json: false, |
| 155 |
read_only: true, |
| 156 |
destructive: false, |
| 157 |
}, |
| 158 |
Spec { |
| 159 |
name: "blame", |
| 160 |
description: "Which change wrote each line of a file, with its line atoms.", |
| 161 |
schema: || obj(json!({ "path": { "type": "string" } }), &["path"]), |
| 162 |
build: |a, _| { |
| 163 |
let mut v = vec!["blame".into(), "--json".into()]; |
| 164 |
positional(&mut v, vec![required(a, "path")?.to_string()]); |
| 165 |
Ok(v) |
| 166 |
}, |
| 167 |
json: true, |
| 168 |
read_only: true, |
| 169 |
destructive: false, |
| 170 |
}, |
| 171 |
Spec { |
| 172 |
name: "bookmarks", |
| 173 |
description: "Bookmarks with their heads and how far each is ahead of or behind its remote; `all: true` adds remotes' bookmarks.", |
| 174 |
schema: || obj(json!({ "all": { "type": "boolean" } }), &[]), |
| 175 |
build: |a, _| { |
| 176 |
let mut v = vec!["bookmark".into(), "list".into(), "--json".into()]; |
| 177 |
if a.get("all").and_then(Value::as_bool).unwrap_or(false) { |
| 178 |
v.push("--all".into()); |
| 179 |
} |
| 180 |
Ok(v) |
| 181 |
}, |
| 182 |
json: true, |
| 183 |
read_only: true, |
| 184 |
destructive: false, |
| 185 |
}, |
| 186 |
Spec { |
| 187 |
name: "tags", |
| 188 |
description: "Tags with their states, messages and signers.", |
| 189 |
schema: none, |
| 190 |
build: |_, _| Ok(vec!["tag".into(), "list".into(), "--json".into()]), |
| 191 |
json: true, |
| 192 |
read_only: true, |
| 193 |
destructive: false, |
| 194 |
}, |
| 195 |
Spec { |
| 196 |
name: "conflicts", |
| 197 |
description: "Which conflicts of the working copy's base the current files resolve, and which remain.", |
| 198 |
schema: none, |
| 199 |
build: |_, _| Ok(vec!["resolve".into(), "--json".into()]), |
| 200 |
json: true, |
| 201 |
read_only: true, |
| 202 |
destructive: false, |
| 203 |
}, |
| 204 |
Spec { |
| 205 |
name: "op_log", |
| 206 |
description: "The operation log, newest first (what undo would take back); `limit` defaults to 20.", |
| 207 |
schema: || obj(json!({ "limit": { "type": "integer", "minimum": 1 } }), &[]), |
| 208 |
build: |a, _| Ok(vec!["op".into(), "log".into(), "--json".into(), opt("--limit", &limit(a, 20))]), |
| 209 |
json: true, |
| 210 |
read_only: true, |
| 211 |
destructive: false, |
| 212 |
}, |
| 213 |
Spec { |
| 214 |
name: "identity", |
| 215 |
description: "Who changes are signed as here: the author, the key, delegates and the signer (an agent home signs keyless, via the agent).", |
| 216 |
schema: none, |
| 217 |
build: |_, _| Ok(vec!["identity".into(), "show".into()]), |
| 218 |
json: false, |
| 219 |
read_only: true, |
| 220 |
destructive: false, |
| 221 |
}, |
| 222 |
Spec { |
| 223 |
name: "commit", |
| 224 |
description: "Record a change with a message: only `paths`, or every changed path with `all: true`. Signed as this server's identity.", |
| 225 |
schema: || obj(json!({ "message": { "type": "string" }, "paths": { "type": "array", "items": { "type": "string" } }, "all": { "type": "boolean" } }), &["message"]), |
| 226 |
build: |a, _| { |
| 227 |
let mut v = vec!["commit".into(), opt("--message", required(a, "message")?)]; |
| 228 |
positional(&mut v, paths_or_all(a)?); |
| 229 |
Ok(v) |
| 230 |
}, |
| 231 |
json: false, |
| 232 |
read_only: false, |
| 233 |
destructive: false, |
| 234 |
}, |
| 235 |
Spec { |
| 236 |
name: "amend", |
| 237 |
description: "Fold edits into the change the working copy sits on: only `paths`, or all with `all: true`; `message` replaces its message too.", |
| 238 |
schema: || obj(json!({ "message": { "type": "string" }, "paths": { "type": "array", "items": { "type": "string" } }, "all": { "type": "boolean" } }), &[]), |
| 239 |
build: |a, _| { |
| 240 |
let mut v = vec!["amend".into()]; |
| 241 |
if let Some(m) = arg_str(a, "message") { |
| 242 |
v.push(opt("--message", m)); |
| 243 |
} |
| 244 |
positional(&mut v, paths_or_all(a)?); |
| 245 |
Ok(v) |
| 246 |
}, |
| 247 |
json: false, |
| 248 |
read_only: false, |
| 249 |
destructive: false, |
| 250 |
}, |
| 251 |
Spec { |
| 252 |
name: "describe", |
| 253 |
description: "Change a head's message (`change` defaults to the one the working copy sits on).", |
| 254 |
schema: || obj(json!({ "message": { "type": "string" }, "change": { "type": "string" } }), &["message"]), |
| 255 |
build: |a, _| { |
| 256 |
let mut v = vec!["describe".into(), "--json".into(), opt("--message", required(a, "message")?)]; |
| 257 |
positional(&mut v, arg_str(a, "change").map(|c| vec![c.to_string()]).unwrap_or_default()); |
| 258 |
Ok(v) |
| 259 |
}, |
| 260 |
json: true, |
| 261 |
read_only: false, |
| 262 |
destructive: false, |
| 263 |
}, |
| 264 |
Spec { |
| 265 |
name: "fetch", |
| 266 |
description: "Fetch from a remote (default origin) without moving local bookmarks.", |
| 267 |
schema: || obj(json!({ "remote": { "type": "string" } }), &[]), |
| 268 |
build: |a, _| remote_cmd("fetch", a), |
| 269 |
json: true, |
| 270 |
read_only: false, |
| 271 |
destructive: false, |
| 272 |
}, |
| 273 |
Spec { |
| 274 |
name: "pull", |
| 275 |
description: "Fetch from a remote (default origin) and bring the current bookmark and working copy up to date.", |
| 276 |
schema: || obj(json!({ "remote": { "type": "string" } }), &[]), |
| 277 |
build: |a, _| remote_cmd("pull", a), |
| 278 |
json: true, |
| 279 |
read_only: false, |
| 280 |
destructive: false, |
| 281 |
}, |
| 282 |
Spec { |
| 283 |
name: "push", |
| 284 |
description: "Push the current bookmark (or `bookmark`) to a remote (default origin). Changes are sealed at push as this server's identity. `force` (drop heads the remote has) needs the server started with --allow-destructive.", |
| 285 |
schema: || obj(json!({ "remote": { "type": "string" }, "bookmark": { "type": "string" }, "force": { "type": "boolean" } }), &[]), |
| 286 |
build: |a, destructive| { |
| 287 |
let mut v = vec!["push".to_string(), "--json".into()]; |
| 288 |
if let Some(b) = arg_str(a, "bookmark") { |
| 289 |
v.push(opt("--bookmark", b)); |
| 290 |
} |
| 291 |
if a.get("force").and_then(Value::as_bool).unwrap_or(false) { |
| 292 |
if !destructive { |
| 293 |
return Err("a forced push can drop the remote's heads; start oxy mcp with --allow-destructive to allow it".into()); |
| 294 |
} |
| 295 |
v.push("--force".into()); |
| 296 |
} |
| 297 |
positional(&mut v, arg_str(a, "remote").map(|r| vec![r.to_string()]).unwrap_or_default()); |
| 298 |
Ok(v) |
| 299 |
}, |
| 300 |
json: true, |
| 301 |
read_only: false, |
| 302 |
destructive: false, |
| 303 |
}, |
| 304 |
Spec { |
| 305 |
name: "tlog_verify", |
| 306 |
description: "Check the hub's transparency log: that it shows everyone the same history, with this repository's entries and each witness's cosignature.", |
| 307 |
schema: none, |
| 308 |
build: |_, _| Ok(vec!["tlog".into(), "verify".into(), "--json".into()]), |
| 309 |
json: true, |
| 310 |
read_only: true, |
| 311 |
destructive: false, |
| 312 |
}, |
| 313 |
Spec { |
| 314 |
name: "undo", |
| 315 |
description: "Take back the last operation (see op_log). Offered only when the server was started with --allow-destructive.", |
| 316 |
schema: none, |
| 317 |
build: |_, _| Ok(vec!["undo".into()]), |
| 318 |
json: false, |
| 319 |
read_only: false, |
| 320 |
destructive: true, |
| 321 |
}, |
| 322 |
] |
| 323 |
} |
| 324 |
|
| 325 |
fn remote_cmd(command: &str, a: &Value) -> Result<Vec<String>, String> { |
| 326 |
let mut v = vec![command.to_string(), "--json".into()]; |
| 327 |
positional( |
| 328 |
&mut v, |
| 329 |
arg_str(a, "remote") |
| 330 |
.map(|r| vec![r.to_string()]) |
| 331 |
.unwrap_or_default(), |
| 332 |
); |
| 333 |
Ok(v) |
| 334 |
} |
| 335 |
|
| 336 |
struct OxyTools { |
| 337 |
exe: PathBuf, |
| 338 |
dir: PathBuf, |
| 339 |
allow_destructive: bool, |
| 340 |
} |
| 341 |
|
| 342 |
impl OxyTools { |
| 343 |
fn offered(&self) -> impl Iterator<Item = Spec> + '_ { |
| 344 |
specs() |
| 345 |
.into_iter() |
| 346 |
.filter(|s| self.allow_destructive || !s.destructive) |
| 347 |
} |
| 348 |
|
| 349 |
fn run(&self, spec: &Spec, argv: Vec<String>) -> ToolOutput { |
| 350 |
let out = Command::new(&self.exe) |
| 351 |
.arg("--plain") |
| 352 |
.args(&argv) |
| 353 |
.current_dir(&self.dir) |
| 354 |
.stdin(Stdio::null()) |
| 355 |
.output(); |
| 356 |
let out = match out { |
| 357 |
Ok(o) => o, |
| 358 |
Err(e) => return ToolOutput::error(format!("could not run oxy: {e}")), |
| 359 |
}; |
| 360 |
let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); |
| 361 |
let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); |
| 362 |
if !out.status.success() { |
| 363 |
let why = if stderr.trim().is_empty() { |
| 364 |
stdout |
| 365 |
} else { |
| 366 |
stderr |
| 367 |
}; |
| 368 |
return ToolOutput::error(format!("oxy {} failed: {}", argv.join(" "), why.trim())); |
| 369 |
} |
| 370 |
let mut result = ToolOutput::ok(stdout.clone()); |
| 371 |
if spec.json { |
| 372 |
result.structured = serde_json::from_str(&stdout).ok(); |
| 373 |
} else if !stderr.trim().is_empty() && stdout.trim().is_empty() { |
| 374 |
|
| 375 |
result.text = stderr; |
| 376 |
} |
| 377 |
if result.text.len() > MAX_TEXT { |
| 378 |
let mut cut = MAX_TEXT; |
| 379 |
while !result.text.is_char_boundary(cut) { |
| 380 |
cut -= 1; |
| 381 |
} |
| 382 |
result.text.truncate(cut); |
| 383 |
result.text.push_str( |
| 384 |
"\n… (cut at 256 KiB: narrow the request, e.g. a smaller limit or one path)", |
| 385 |
); |
| 386 |
result.structured = None; |
| 387 |
} |
| 388 |
result |
| 389 |
} |
| 390 |
} |
| 391 |
|
| 392 |
impl Tools for OxyTools { |
| 393 |
fn list(&self) -> Vec<Tool> { |
| 394 |
self.offered() |
| 395 |
.map(|s| Tool { |
| 396 |
name: s.name.into(), |
| 397 |
description: s.description.into(), |
| 398 |
input_schema: (s.schema)(), |
| 399 |
read_only: s.read_only, |
| 400 |
destructive: s.destructive, |
| 401 |
}) |
| 402 |
.collect() |
| 403 |
} |
| 404 |
|
| 405 |
fn call(&self, name: &str, args: &Value) -> Option<ToolOutput> { |
| 406 |
let spec = self.offered().find(|s| s.name == name)?; |
| 407 |
Some(match (spec.build)(args, self.allow_destructive) { |
| 408 |
Ok(argv) => self.run(&spec, argv), |
| 409 |
Err(why) => ToolOutput::error(why), |
| 410 |
}) |
| 411 |
} |
| 412 |
} |
| 413 |
|
| 414 |
|
| 415 |
|
| 416 |
fn signer_line(exe: &Path, dir: &Path) -> String { |
| 417 |
let out = Command::new(exe) |
| 418 |
.args(["--plain", "identity", "show"]) |
| 419 |
.current_dir(dir) |
| 420 |
.stdin(Stdio::null()) |
| 421 |
.output(); |
| 422 |
match out { |
| 423 |
Ok(o) if o.status.success() => { |
| 424 |
let text = String::from_utf8_lossy(&o.stdout); |
| 425 |
let who = text.lines().next().unwrap_or("").trim_start_matches(['#', '▍', ' ']).trim().to_string(); |
| 426 |
let signing = text.lines().find(|l| l.trim_start().starts_with("signing")).map(|l| l.trim().to_string()); |
| 427 |
match signing { |
| 428 |
Some(s) => format!("{who}; {s}"), |
| 429 |
None => who, |
| 430 |
} |
| 431 |
} |
| 432 |
_ => "no identity is configured: commits will be refused until one is (oxy identity create, or an agent home with OXY_HOME)".into(), |
| 433 |
} |
| 434 |
} |
| 435 |
|
| 436 |
pub fn serve(dir: Option<PathBuf>, allow_destructive: bool) -> Result<i32> { |
| 437 |
let dir = match dir { |
| 438 |
Some(d) => d, |
| 439 |
None => std::env::current_dir()?, |
| 440 |
}; |
| 441 |
if !dir.join(oxy_core::repo::OXY_DIR).is_dir() { |
| 442 |
bail!("{} is not the root of an Oxygen repository (no {}): start oxy mcp in a checkout's root, or name it", dir.display(), oxy_core::repo::OXY_DIR); |
| 443 |
} |
| 444 |
let exe = std::env::current_exe()?; |
| 445 |
let signer = signer_line(&exe, &dir); |
| 446 |
let info = ServerInfo { |
| 447 |
name: "oxy".into(), |
| 448 |
version: env!("CARGO_PKG_VERSION").into(), |
| 449 |
instructions: format!( |
| 450 |
"Oxygen version control for the repository at {}. Changes are signed as: {signer}. Prefer these tools to running oxy in a shell. \ |
| 451 |
commit and amend take explicit paths (or all: true). {}", |
| 452 |
dir.display(), |
| 453 |
if allow_destructive { "Destructive tools (undo, forced push) are enabled: use them only when asked." } else { "Destructive tools (undo, forced push) are off." } |
| 454 |
), |
| 455 |
}; |
| 456 |
let tools = OxyTools { |
| 457 |
exe, |
| 458 |
dir, |
| 459 |
allow_destructive, |
| 460 |
}; |
| 461 |
let stdin = std::io::stdin(); |
| 462 |
mcp::serve(&info, &tools, stdin.lock(), std::io::stdout().lock())?; |
| 463 |
Ok(0) |
| 464 |
} |
| 465 |
|
| 466 |
#[cfg(test)] |
| 467 |
mod tests { |
| 468 |
use super::*; |
| 469 |
|
| 470 |
fn build(name: &str, args: Value, destructive: bool) -> Result<Vec<String>, String> { |
| 471 |
let spec = specs().into_iter().find(|s| s.name == name).unwrap(); |
| 472 |
(spec.build)(&args, destructive) |
| 473 |
} |
| 474 |
|
| 475 |
#[test] |
| 476 |
fn values_never_read_as_flags() { |
| 477 |
assert_eq!( |
| 478 |
build( |
| 479 |
"commit", |
| 480 |
json!({"message": "-m oops", "paths": ["-rf"]}), |
| 481 |
false |
| 482 |
) |
| 483 |
.unwrap(), |
| 484 |
["commit", "--message=-m oops", "--", "-rf"] |
| 485 |
); |
| 486 |
assert_eq!( |
| 487 |
build("cat", json!({"path": "--help", "change": "main"}), false).unwrap(), |
| 488 |
["cat", "--change=main", "--", "--help"] |
| 489 |
); |
| 490 |
assert_eq!( |
| 491 |
build("log", json!({"limit": 5000}), false).unwrap(), |
| 492 |
["log", "--json", "--limit=1000"], |
| 493 |
"limit is capped" |
| 494 |
); |
| 495 |
} |
| 496 |
|
| 497 |
#[test] |
| 498 |
fn commits_name_what_goes_in() { |
| 499 |
assert!(build("commit", json!({"message": "x"}), false) |
| 500 |
.unwrap_err() |
| 501 |
.contains("all: true")); |
| 502 |
assert!(build( |
| 503 |
"commit", |
| 504 |
json!({"message": "x", "paths": ["a"], "all": true}), |
| 505 |
false |
| 506 |
) |
| 507 |
.is_err()); |
| 508 |
assert_eq!( |
| 509 |
build("commit", json!({"message": "x", "all": true}), false).unwrap(), |
| 510 |
["commit", "--message=x"] |
| 511 |
); |
| 512 |
assert!(build("commit", json!({"message": " ", "all": true}), false) |
| 513 |
.unwrap_err() |
| 514 |
.contains("message")); |
| 515 |
} |
| 516 |
|
| 517 |
#[test] |
| 518 |
fn destructive_tools_need_the_flag() { |
| 519 |
let off = OxyTools { |
| 520 |
exe: "oxy".into(), |
| 521 |
dir: ".".into(), |
| 522 |
allow_destructive: false, |
| 523 |
}; |
| 524 |
let on = OxyTools { |
| 525 |
exe: "oxy".into(), |
| 526 |
dir: ".".into(), |
| 527 |
allow_destructive: true, |
| 528 |
}; |
| 529 |
assert!(!off.list().iter().any(|t| t.name == "undo")); |
| 530 |
assert!( |
| 531 |
off.call("undo", &json!({})).is_none(), |
| 532 |
"an unoffered tool is unknown" |
| 533 |
); |
| 534 |
assert!(on.list().iter().any(|t| t.name == "undo" && t.destructive)); |
| 535 |
assert!(build("push", json!({"force": true}), false) |
| 536 |
.unwrap_err() |
| 537 |
.contains("--allow-destructive")); |
| 538 |
assert_eq!( |
| 539 |
build("push", json!({"force": true, "remote": "earth"}), true).unwrap(), |
| 540 |
["push", "--json", "--force", "--", "earth"] |
| 541 |
); |
| 542 |
} |
| 543 |
} |