1mod cli;
2mod completion;
3
4use std::collections::BTreeSet;
5use std::env;
6use std::ffi::OsString;
7use std::fs;
8use std::path::{Component, Path, PathBuf};
9use std::process::Command as ProcessCommand;
10
11use clap::Parser;
12use clap::error::ErrorKind;
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15
16use cli::{ChangeMode, Cli, Command, CommonArgs, CreateArgs, OutputFormat, ValidateArgs};
17
18use nils_common::cli_contract::exit;
19use nils_common::fs::{display_path, normalize_path as normalize_absolute_path};
20
21const EXIT_OK: i32 = exit::SUCCESS;
22const EXIT_RUNTIME_OR_SCOPE: i32 = exit::RUNTIME;
23const EXIT_USAGE: i32 = exit::USAGE;
24
25const LOCK_DOCUMENT_VERSION: &str = "agent-scope-lock.v1";
26const LOCK_FILE_NAME: &str = "agent-scope-lock.json";
27
28const CREATE_SCHEMA_VERSION: &str = "cli.agent-scope-lock.create.v1";
29const READ_SCHEMA_VERSION: &str = "cli.agent-scope-lock.read.v1";
30const VALIDATE_SCHEMA_VERSION: &str = "cli.agent-scope-lock.validate.v1";
31const CLEAR_SCHEMA_VERSION: &str = "cli.agent-scope-lock.clear.v1";
32
33const CREATE_COMMAND: &str = "agent-scope-lock create";
34const READ_COMMAND: &str = "agent-scope-lock read";
35const VALIDATE_COMMAND: &str = "agent-scope-lock validate";
36const CLEAR_COMMAND: &str = "agent-scope-lock clear";
37
38pub fn run() -> i32 {
39 run_with_args(env::args_os())
40}
41
42pub fn run_with_args<I, T>(args: I) -> i32
43where
44 I: IntoIterator<Item = T>,
45 T: Into<OsString> + Clone,
46{
47 let cli = match Cli::try_parse_from(args) {
48 Ok(cli) => cli,
49 Err(err) => {
50 let code = match err.kind() {
51 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => err.exit_code(),
52 _ => EXIT_USAGE,
53 };
54 let _ = err.print();
55 return code;
56 }
57 };
58
59 dispatch(cli)
60}
61
62fn dispatch(cli: Cli) -> i32 {
63 match cli.command {
64 Command::Create(args) => run_create(args),
65 Command::Read(args) => run_read(args),
66 Command::Validate(args) => run_validate(args),
67 Command::Clear(args) => run_clear(args),
68 Command::Completion(args) => completion::run(args.shell),
69 }
70}
71
72fn run_create(args: CreateArgs) -> i32 {
73 match create_lock(&args) {
74 Ok(result) => render_create_success(args.common.format, &result),
75 Err(err) => render_error(
76 CREATE_SCHEMA_VERSION,
77 CREATE_COMMAND,
78 args.common.format,
79 err,
80 ),
81 }
82}
83
84fn run_read(args: CommonArgs) -> i32 {
85 match read_lock_result(&args) {
86 Ok(result) => render_read_success(args.format, &result),
87 Err(err) => render_error(READ_SCHEMA_VERSION, READ_COMMAND, args.format, err),
88 }
89}
90
91fn run_validate(args: ValidateArgs) -> i32 {
92 match validate_lock(&args) {
93 Ok(report) if report.violations.is_empty() => {
94 render_validate_success(args.common.format, &report)
95 }
96 Ok(report) => render_validate_violations(args.common.format, &report),
97 Err(err) => render_error(
98 VALIDATE_SCHEMA_VERSION,
99 VALIDATE_COMMAND,
100 args.common.format,
101 err,
102 ),
103 }
104}
105
106fn run_clear(args: CommonArgs) -> i32 {
107 match clear_lock(&args) {
108 Ok(result) => render_clear_success(args.format, &result),
109 Err(err) => render_error(CLEAR_SCHEMA_VERSION, CLEAR_COMMAND, args.format, err),
110 }
111}
112
113fn create_lock(args: &CreateArgs) -> Result<LockResult, CliError> {
114 if args.paths.is_empty() {
115 return Err(CliError::usage(
116 "missing-path",
117 "create requires at least one --path",
118 Some(json!({ "flag": "--path" })),
119 ));
120 }
121
122 let lock_file = resolve_lock_file(args.common.lock_file.as_deref())?;
123 if lock_file.exists() && !args.force {
124 return Err(CliError::runtime(
125 "lock-exists",
126 format!(
127 "{} already exists; pass --force to overwrite",
128 lock_file.display()
129 ),
130 Some(json!({ "lock_file": display_path(&lock_file), "force_flag": "--force" })),
131 ));
132 }
133
134 let repo_root = git_repo_root()?;
135 let allowed_paths = normalize_allowed_paths(&repo_root, &args.paths)?;
136 let lock = LockDocument {
137 schema_version: LOCK_DOCUMENT_VERSION.to_string(),
138 allowed_paths,
139 owner: args.owner.clone().filter(|value| !value.is_empty()),
140 note: args.note.clone().filter(|value| !value.is_empty()),
141 };
142
143 if let Some(parent) = lock_file.parent() {
144 fs::create_dir_all(parent).map_err(|err| {
145 CliError::runtime(
146 "lock-parent-create-failed",
147 format!("failed to create {}: {err}", parent.display()),
148 Some(json!({ "path": display_path(parent) })),
149 )
150 })?;
151 }
152
153 let mut contents = serde_json::to_string_pretty(&lock).map_err(|err| {
154 CliError::runtime(
155 "lock-render-failed",
156 format!("failed to render lock json: {err}"),
157 None,
158 )
159 })?;
160 contents.push('\n');
161 fs::write(&lock_file, contents).map_err(|err| {
162 CliError::runtime(
163 "lock-write-failed",
164 format!("failed to write {}: {err}", lock_file.display()),
165 Some(json!({ "lock_file": display_path(&lock_file) })),
166 )
167 })?;
168
169 Ok(LockResult {
170 lock_file: display_path(&lock_file),
171 lock,
172 })
173}
174
175fn read_lock_result(args: &CommonArgs) -> Result<LockResult, CliError> {
176 let lock_file = resolve_lock_file(args.lock_file.as_deref())?;
177 let lock = read_lock_document(&lock_file)?;
178 Ok(LockResult {
179 lock_file: display_path(&lock_file),
180 lock,
181 })
182}
183
184fn validate_lock(args: &ValidateArgs) -> Result<ValidateReport, CliError> {
185 let lock_file = resolve_lock_file(args.common.lock_file.as_deref())?;
186 let lock = read_lock_document(&lock_file)?;
187 let changed_paths = changed_paths(args.changes)?;
188 let violations: Vec<ScopeViolation> = changed_paths
189 .iter()
190 .filter(|path| !is_allowed_path(path, &lock.allowed_paths))
191 .map(|path| ScopeViolation {
192 path: path.clone(),
193 reason: "changed path is outside allowed prefixes".to_string(),
194 })
195 .collect();
196
197 Ok(ValidateReport {
198 lock_file: display_path(&lock_file),
199 mode: change_mode_name(args.changes).to_string(),
200 allowed_paths: lock.allowed_paths,
201 changed_paths,
202 violations,
203 })
204}
205
206fn clear_lock(args: &CommonArgs) -> Result<ClearResult, CliError> {
207 let lock_file = resolve_lock_file(args.lock_file.as_deref())?;
208 let removed = match fs::remove_file(&lock_file) {
209 Ok(()) => true,
210 Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
211 Err(err) => {
212 return Err(CliError::runtime(
213 "lock-clear-failed",
214 format!("failed to remove {}: {err}", lock_file.display()),
215 Some(json!({ "lock_file": display_path(&lock_file) })),
216 ));
217 }
218 };
219
220 Ok(ClearResult {
221 lock_file: display_path(&lock_file),
222 removed,
223 })
224}
225
226fn read_lock_document(lock_file: &Path) -> Result<LockDocument, CliError> {
227 let contents = fs::read_to_string(lock_file).map_err(|err| {
228 if err.kind() == std::io::ErrorKind::NotFound {
229 CliError::runtime(
230 "missing-lock",
231 format!("scope lock not found: {}", lock_file.display()),
232 Some(json!({ "lock_file": display_path(lock_file) })),
233 )
234 } else {
235 CliError::runtime(
236 "lock-read-failed",
237 format!("failed to read {}: {err}", lock_file.display()),
238 Some(json!({ "lock_file": display_path(lock_file) })),
239 )
240 }
241 })?;
242
243 let lock: LockDocument = serde_json::from_str(&contents).map_err(|err| {
244 CliError::runtime(
245 "invalid-lock-json",
246 format!("failed to parse {}: {err}", lock_file.display()),
247 Some(json!({ "lock_file": display_path(lock_file) })),
248 )
249 })?;
250
251 if lock.schema_version != LOCK_DOCUMENT_VERSION {
252 return Err(CliError::runtime(
253 "unsupported-lock-version",
254 format!(
255 "unsupported lock schema_version {}; expected {}",
256 lock.schema_version, LOCK_DOCUMENT_VERSION
257 ),
258 Some(json!({
259 "lock_file": display_path(lock_file),
260 "schema_version": lock.schema_version,
261 "expected": LOCK_DOCUMENT_VERSION
262 })),
263 ));
264 }
265 if lock.allowed_paths.is_empty() {
266 return Err(CliError::runtime(
267 "invalid-lock",
268 "scope lock has no allowed_paths",
269 Some(json!({ "lock_file": display_path(lock_file) })),
270 ));
271 }
272
273 Ok(lock)
274}
275
276fn normalize_allowed_paths(repo_root: &Path, paths: &[PathBuf]) -> Result<Vec<String>, CliError> {
277 let repo_root = normalize_absolute_path(repo_root);
278 let mut normalized = BTreeSet::new();
279
280 for path in paths {
281 let absolute = if path.is_absolute() {
282 normalize_absolute_path(path)
283 } else {
284 normalize_absolute_path(&repo_root.join(path))
285 };
286
287 let relative = absolute.strip_prefix(&repo_root).map_err(|_| {
288 CliError::usage(
289 "path-outside-repo",
290 format!(
291 "{} is outside repository {}",
292 path.display(),
293 repo_root.display()
294 ),
295 Some(json!({ "path": display_path(path), "repo_root": display_path(&repo_root) })),
296 )
297 })?;
298
299 let normalized_path = repo_relative_path(relative)?;
300 normalized.insert(normalized_path);
301 }
302
303 Ok(normalized.into_iter().collect())
304}
305
306fn repo_relative_path(path: &Path) -> Result<String, CliError> {
307 if path.as_os_str().is_empty() {
308 return Ok(".".to_string());
309 }
310
311 let parts: Vec<String> = path
312 .components()
313 .filter_map(|component| match component {
314 Component::Normal(part) => Some(part.to_string_lossy().to_string()),
315 Component::CurDir => None,
316 _ => Some(component.as_os_str().to_string_lossy().to_string()),
317 })
318 .collect();
319 let joined = parts.join("/");
320 let trimmed = joined.trim_matches('/').to_string();
321
322 if trimmed.is_empty() {
323 Ok(".".to_string())
324 } else if trimmed == ".git" || trimmed.starts_with(".git/") {
325 Err(CliError::usage(
326 "git-dir-not-allowed",
327 "allowed paths must not target the git metadata directory",
328 Some(json!({ "path": trimmed })),
329 ))
330 } else {
331 Ok(trimmed)
332 }
333}
334
335fn is_allowed_path(path: &str, allowed_paths: &[String]) -> bool {
336 allowed_paths.iter().any(|allowed| {
337 allowed == "." || path == allowed || path.starts_with(&format!("{allowed}/"))
338 })
339}
340
341fn changed_paths(mode: ChangeMode) -> Result<Vec<String>, CliError> {
342 let mut paths = BTreeSet::new();
343
344 match mode {
345 ChangeMode::All => {
346 collect_git_paths(&mut paths, &["diff", "--name-only"])?;
347 collect_git_paths(&mut paths, &["diff", "--name-only", "--cached"])?;
348 collect_git_paths(&mut paths, &["ls-files", "--others", "--exclude-standard"])?;
349 }
350 ChangeMode::Staged => {
351 collect_git_paths(&mut paths, &["diff", "--name-only", "--cached"])?;
352 }
353 ChangeMode::Unstaged => {
354 collect_git_paths(&mut paths, &["diff", "--name-only"])?;
355 collect_git_paths(&mut paths, &["ls-files", "--others", "--exclude-standard"])?;
356 }
357 }
358
359 Ok(paths.into_iter().collect())
360}
361
362fn collect_git_paths(paths: &mut BTreeSet<String>, args: &[&str]) -> Result<(), CliError> {
363 let output = git_stdout(args)?;
364 for line in output.lines() {
365 let path = line.trim();
366 if !path.is_empty() {
367 paths.insert(path.to_string());
368 }
369 }
370 Ok(())
371}
372
373fn resolve_lock_file(explicit: Option<&Path>) -> Result<PathBuf, CliError> {
374 match explicit {
375 Some(path) => Ok(absolute_path(path)?),
376 None => {
377 let git_path = git_stdout(&["rev-parse", "--git-path", LOCK_FILE_NAME])?;
378 let git_path = PathBuf::from(git_path.trim());
379 if git_path.is_absolute() {
380 Ok(normalize_absolute_path(&git_path))
381 } else {
382 Ok(absolute_path(&git_path)?)
383 }
384 }
385 }
386}
387
388fn git_repo_root() -> Result<PathBuf, CliError> {
389 let root = git_stdout(&["rev-parse", "--show-toplevel"])?;
390 Ok(normalize_absolute_path(Path::new(root.trim())))
391}
392
393fn git_stdout(args: &[&str]) -> Result<String, CliError> {
394 let output = ProcessCommand::new("git")
395 .args(args)
396 .output()
397 .map_err(|err| {
398 CliError::runtime(
399 "git-spawn-failed",
400 format!("failed to run git: {err}"),
401 None,
402 )
403 })?;
404
405 if !output.status.success() {
406 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
407 return Err(CliError::runtime(
408 "git-command-failed",
409 if stderr.is_empty() {
410 format!("git {} failed", args.join(" "))
411 } else {
412 format!("git {} failed: {stderr}", args.join(" "))
413 },
414 Some(json!({ "git_args": args })),
415 ));
416 }
417
418 Ok(String::from_utf8_lossy(&output.stdout).to_string())
419}
420
421fn absolute_path(path: &Path) -> Result<PathBuf, CliError> {
422 if path.is_absolute() {
423 return Ok(normalize_absolute_path(path));
424 }
425
426 let current_dir = env::current_dir().map_err(|err| {
427 CliError::runtime(
428 "cwd-unavailable",
429 format!("failed to read current directory: {err}"),
430 None,
431 )
432 })?;
433 Ok(normalize_absolute_path(¤t_dir.join(path)))
434}
435
436fn change_mode_name(mode: ChangeMode) -> &'static str {
437 match mode {
438 ChangeMode::All => "all",
439 ChangeMode::Staged => "staged",
440 ChangeMode::Unstaged => "unstaged",
441 }
442}
443
444fn render_create_success(format: OutputFormat, result: &LockResult) -> i32 {
445 match format {
446 OutputFormat::Json => print_json_success(CREATE_SCHEMA_VERSION, CREATE_COMMAND, result)
447 .unwrap_or_else(render_json_failure),
448 OutputFormat::Text => {
449 println!("created scope lock: {}", result.lock_file);
450 print_lock_text(&result.lock);
451 EXIT_OK
452 }
453 }
454}
455
456fn render_read_success(format: OutputFormat, result: &LockResult) -> i32 {
457 match format {
458 OutputFormat::Json => print_json_success(READ_SCHEMA_VERSION, READ_COMMAND, result)
459 .unwrap_or_else(render_json_failure),
460 OutputFormat::Text => {
461 println!("lock file: {}", result.lock_file);
462 print_lock_text(&result.lock);
463 EXIT_OK
464 }
465 }
466}
467
468fn render_validate_success(format: OutputFormat, report: &ValidateReport) -> i32 {
469 match format {
470 OutputFormat::Json => print_json_success(VALIDATE_SCHEMA_VERSION, VALIDATE_COMMAND, report)
471 .unwrap_or_else(render_json_failure),
472 OutputFormat::Text => {
473 println!(
474 "scope ok: {} changed path(s) allowed by {}",
475 report.changed_paths.len(),
476 report.lock_file
477 );
478 EXIT_OK
479 }
480 }
481}
482
483fn render_validate_violations(format: OutputFormat, report: &ValidateReport) -> i32 {
484 match format {
485 OutputFormat::Json => print_json_error(
486 VALIDATE_SCHEMA_VERSION,
487 VALIDATE_COMMAND,
488 "scope-violations",
489 "changed paths are outside allowed prefixes",
490 Some(json!({
491 "lock_file": report.lock_file,
492 "mode": report.mode,
493 "allowed_paths": report.allowed_paths,
494 "changed_paths": report.changed_paths,
495 "violations": report.violations,
496 })),
497 EXIT_RUNTIME_OR_SCOPE,
498 )
499 .unwrap_or_else(render_json_failure),
500 OutputFormat::Text => {
501 eprintln!("agent-scope-lock: scope violations:");
502 for violation in &report.violations {
503 eprintln!(" - {}", violation.path);
504 }
505 eprintln!("allowed paths:");
506 for path in &report.allowed_paths {
507 eprintln!(" - {path}");
508 }
509 EXIT_RUNTIME_OR_SCOPE
510 }
511 }
512}
513
514fn render_clear_success(format: OutputFormat, result: &ClearResult) -> i32 {
515 match format {
516 OutputFormat::Json => print_json_success(CLEAR_SCHEMA_VERSION, CLEAR_COMMAND, result)
517 .unwrap_or_else(render_json_failure),
518 OutputFormat::Text => {
519 if result.removed {
520 println!("cleared scope lock: {}", result.lock_file);
521 } else {
522 println!("scope lock already clear: {}", result.lock_file);
523 }
524 EXIT_OK
525 }
526 }
527}
528
529fn print_lock_text(lock: &LockDocument) {
530 if let Some(owner) = lock.owner.as_deref() {
531 println!("owner: {owner}");
532 }
533 if let Some(note) = lock.note.as_deref() {
534 println!("note: {note}");
535 }
536 println!("allowed paths:");
537 for path in &lock.allowed_paths {
538 println!(" - {path}");
539 }
540}
541
542fn render_error(
543 schema_version: &'static str,
544 command: &'static str,
545 format: OutputFormat,
546 err: CliError,
547) -> i32 {
548 if format == OutputFormat::Json {
549 return print_json_error(
550 schema_version,
551 command,
552 err.code,
553 &err.message,
554 err.details,
555 err.exit_code,
556 )
557 .unwrap_or_else(render_json_failure);
558 }
559
560 eprintln!("agent-scope-lock: error: {}", err.message);
561 err.exit_code
562}
563
564fn print_json_success<T: Serialize>(
565 schema_version: &'static str,
566 command: &'static str,
567 result: &T,
568) -> Result<i32, serde_json::Error> {
569 let envelope = SuccessEnvelope {
570 schema_version,
571 command,
572 ok: true,
573 result,
574 };
575 println!("{}", serde_json::to_string_pretty(&envelope)?);
576 Ok(EXIT_OK)
577}
578
579fn print_json_error(
580 schema_version: &'static str,
581 command: &'static str,
582 code: &'static str,
583 message: &str,
584 details: Option<Value>,
585 exit_code: i32,
586) -> Result<i32, serde_json::Error> {
587 let envelope = ErrorEnvelope {
588 schema_version,
589 command,
590 ok: false,
591 error: ErrorBody {
592 code,
593 message,
594 details,
595 },
596 };
597 println!("{}", serde_json::to_string_pretty(&envelope)?);
598 Ok(exit_code)
599}
600
601fn render_json_failure(err: serde_json::Error) -> i32 {
602 eprintln!("agent-scope-lock: error: failed to render json: {err}");
603 EXIT_RUNTIME_OR_SCOPE
604}
605
606#[derive(Debug)]
607struct CliError {
608 code: &'static str,
609 message: String,
610 details: Option<Value>,
611 exit_code: i32,
612}
613
614impl CliError {
615 fn usage(code: &'static str, message: impl Into<String>, details: Option<Value>) -> Self {
616 Self {
617 code,
618 message: message.into(),
619 details,
620 exit_code: EXIT_USAGE,
621 }
622 }
623
624 fn runtime(code: &'static str, message: impl Into<String>, details: Option<Value>) -> Self {
625 Self {
626 code,
627 message: message.into(),
628 details,
629 exit_code: EXIT_RUNTIME_OR_SCOPE,
630 }
631 }
632}
633
634#[derive(Debug, Deserialize, Serialize)]
635pub struct LockDocument {
636 pub schema_version: String,
637 pub allowed_paths: Vec<String>,
638 #[serde(skip_serializing_if = "Option::is_none")]
639 pub owner: Option<String>,
640 #[serde(skip_serializing_if = "Option::is_none")]
641 pub note: Option<String>,
642}
643
644#[derive(Debug, Serialize)]
645pub struct LockResult {
646 pub lock_file: String,
647 pub lock: LockDocument,
648}
649
650#[derive(Debug, Serialize)]
651pub struct ClearResult {
652 pub lock_file: String,
653 pub removed: bool,
654}
655
656#[derive(Debug, Serialize)]
657pub struct ValidateReport {
658 pub lock_file: String,
659 pub mode: String,
660 pub allowed_paths: Vec<String>,
661 pub changed_paths: Vec<String>,
662 pub violations: Vec<ScopeViolation>,
663}
664
665#[derive(Debug, Serialize)]
666pub struct ScopeViolation {
667 pub path: String,
668 pub reason: String,
669}
670
671#[derive(Serialize)]
672struct SuccessEnvelope<'a, T: Serialize> {
673 schema_version: &'static str,
674 command: &'static str,
675 ok: bool,
676 result: &'a T,
677}
678
679#[derive(Serialize)]
680struct ErrorEnvelope<'a> {
681 schema_version: &'static str,
682 command: &'static str,
683 ok: bool,
684 error: ErrorBody<'a>,
685}
686
687#[derive(Serialize)]
688struct ErrorBody<'a> {
689 code: &'static str,
690 message: &'a str,
691 #[serde(skip_serializing_if = "Option::is_none")]
692 details: Option<Value>,
693}
694
695#[cfg(test)]
696mod tests {
697 use super::{is_allowed_path, normalize_absolute_path, repo_relative_path};
698 use std::path::Path;
699
700 #[test]
701 fn allowed_prefix_matching_is_component_aware() {
702 let allowed = vec!["src".to_string(), "Cargo.toml".to_string()];
703
704 assert!(is_allowed_path("src/lib.rs", &allowed));
705 assert!(is_allowed_path("src", &allowed));
706 assert!(is_allowed_path("Cargo.toml", &allowed));
707 assert!(!is_allowed_path("src-next/lib.rs", &allowed));
708 assert!(!is_allowed_path("Cargo.toml.bak", &allowed));
709 }
710
711 #[test]
712 fn dot_allows_entire_repo() {
713 let allowed = vec![".".to_string()];
714 assert!(is_allowed_path("anything/here.txt", &allowed));
715 }
716
717 #[test]
718 fn path_normalization_handles_parent_segments() {
719 let path = normalize_absolute_path(Path::new("/tmp/repo/src/../README.md"));
720 assert_eq!(path, Path::new("/tmp/repo/README.md"));
721 }
722
723 #[test]
724 fn empty_repo_relative_path_becomes_dot() {
725 assert_eq!(repo_relative_path(Path::new("")).expect("path"), ".");
726 }
727}