Skip to main content

nils_agent_scope_lock/
lib.rs

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(&current_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}