Skip to main content

leviath_cli/commands/
pack.rs

1//! `lev pack` - Bundle an agent project for distribution.
2
3use clap::Args;
4use leviath_package::AgentBundler;
5use std::path::{Path, PathBuf};
6
7use leviath_core::manifest::parse_manifest;
8
9#[derive(Args)]
10pub struct PackArgs {
11    /// Path to agent project (default: current directory)
12    #[arg(value_name = "PATH")]
13    pub path: Option<String>,
14
15    /// Output file path (default: {name}-{version}.leviath-bundle)
16    #[arg(short, long)]
17    pub output: Option<String>,
18}
19
20pub async fn execute(args: PackArgs) -> anyhow::Result<()> {
21    execute_with_bundle(args, &|dir| AgentBundler::new().bundle(dir)).await
22}
23
24/// [`execute`] with an injectable bundling operation.
25///
26/// The `bundle` closure is a trait object so its failure arm (`bundler.bundle`
27/// erroring) can be exercised on every platform. Reaching that arm through the
28/// real bundler requires the directory walk itself to fail, which is only
29/// possible OS-agnostically via injection inside `leviath-package`; here the
30/// simpler seam is to inject the whole bundle step. Production always passes
31/// the real `AgentBundler`.
32async fn execute_with_bundle(
33    args: PackArgs,
34    bundle: &dyn Fn(&Path) -> anyhow::Result<Vec<u8>>,
35) -> anyhow::Result<()> {
36    let path = args.path.unwrap_or_else(|| ".".to_string());
37    let project_path = Path::new(&path);
38
39    tracing::info!("Packing agent");
40
41    // Find and parse agent.leviath to get name + version
42    let manifest_path = find_manifest(project_path)?;
43    let manifest_content = std::fs::read_to_string(&manifest_path)
44        .map_err(|e| anyhow::anyhow!("Failed to read manifest: {}", e))?;
45    let blueprint = parse_manifest(&manifest_content)?;
46
47    println!("Packing agent: {} v{}", blueprint.name, blueprint.version);
48
49    // Determine output path
50    let output_path =
51        determine_output_path(args.output.as_deref(), &blueprint.name, &blueprint.version);
52
53    // Bundle the project
54    let project_dir = manifest_path.parent().unwrap_or(Path::new("."));
55
56    let data = bundle(project_dir)?;
57    let bundle_size = data.len();
58
59    std::fs::write(&output_path, &data).map_err(|e| {
60        anyhow::anyhow!(
61            "Failed to write bundle to '{}': {}",
62            output_path.display(),
63            e
64        )
65    })?;
66
67    // Print summary
68    println!("Bundle written to: {}", output_path.display());
69    println!("Bundle size: {}", format_size(bundle_size));
70
71    // List contents summary
72    println!("\nContents:");
73    let file_count = count_files(project_dir);
74    println!("  {} files bundled", file_count);
75    println!("  Manifest: agent.leviath");
76
77    let scripts_dir = project_dir.join("scripts");
78    if scripts_dir.exists() {
79        let script_count = count_files(&scripts_dir);
80        println!("  Scripts: {} files", script_count);
81    }
82
83    let tests_dir = project_dir.join("tests");
84    if tests_dir.exists() {
85        let test_count = count_files(&tests_dir);
86        println!("  Tests: {} files", test_count);
87    }
88
89    println!("\nDone! Install with: lev add {}", output_path.display());
90
91    Ok(())
92}
93
94/// Resolves the bundle output path.
95fn determine_output_path(output: Option<&str>, name: &str, version: &str) -> PathBuf {
96    match output {
97        Some(out) => PathBuf::from(out),
98        None => PathBuf::from(format!("{}-{}.leviath-bundle", name, version)),
99    }
100}
101
102fn find_manifest(project_path: &Path) -> anyhow::Result<PathBuf> {
103    find_manifest_with_cwd(project_path, &std::env::current_dir().unwrap_or_default())
104}
105
106fn find_manifest_with_cwd(project_path: &Path, cwd: &Path) -> anyhow::Result<PathBuf> {
107    if project_path.is_file()
108        && project_path.file_name() == Some(std::ffi::OsStr::new("agent.leviath"))
109    {
110        return Ok(project_path.to_path_buf());
111    }
112
113    if project_path.is_dir() {
114        let manifest = project_path.join("agent.leviath");
115        if manifest.exists() {
116            return Ok(manifest);
117        }
118    }
119
120    let current_manifest = cwd.join("agent.leviath");
121    if current_manifest.exists() {
122        return Ok(current_manifest);
123    }
124
125    anyhow::bail!(
126        "Could not find agent.leviath in {} or current directory",
127        project_path.display()
128    )
129}
130
131fn format_size(bytes: usize) -> String {
132    if bytes < 1024 {
133        format!("{} B", bytes)
134    } else if bytes < 1024 * 1024 {
135        format!("{:.1} KB", bytes as f64 / 1024.0)
136    } else {
137        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
138    }
139}
140
141/// The real directory reader used in production. A named function (rather than
142/// an inline closure) so tests that need to pass a reader which their code path
143/// never actually invokes don't introduce an uncovered closure-body region.
144fn real_read_dir(p: &Path) -> std::io::Result<std::fs::ReadDir> {
145    std::fs::read_dir(p)
146}
147
148/// Count files in `dir` recursively; returns 0 on I/O errors.
149fn count_files(dir: &Path) -> usize {
150    count_files_with(dir, &real_read_dir)
151}
152
153/// [`count_files`] with an injectable directory reader so the "read_dir failed
154/// on an existing directory -> return the count so far" arm can be exercised
155/// deterministically on every platform. That arm is otherwise only reachable
156/// via a `chmod 0o000` directory (Unix-only). A trait object (not `impl Fn`)
157/// keeps every caller sharing one monomorphization. Production always passes
158/// `std::fs::read_dir`.
159fn count_files_with(
160    dir: &Path,
161    read_dir: &dyn Fn(&Path) -> std::io::Result<std::fs::ReadDir>,
162) -> usize {
163    let mut count = 0;
164    if dir.is_dir()
165        && let Ok(entries) = read_dir(dir)
166    {
167        for entry in entries.filter_map(|e| e.ok()) {
168            count += count_path(&entry.path(), read_dir);
169        }
170    }
171    count
172}
173
174/// Count the files contributed by a single walked path: 1 for a regular file,
175/// the recursive count for a directory, and 0 for anything else (a broken
176/// symlink, a special file, or a path that vanished mid-walk). Split out so the
177/// "neither a file nor a directory" arm is testable OS-agnostically (a
178/// nonexistent path is neither), rather than only via a Unix broken symlink.
179fn count_path(path: &Path, read_dir: &dyn Fn(&Path) -> std::io::Result<std::fs::ReadDir>) -> usize {
180    if path.is_file() {
181        1
182    } else if path.is_dir() {
183        count_files_with(path, read_dir)
184    } else {
185        0
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::test_support::with_tracing;
193
194    // ─── tracing subscriber ────────────────────────────────────────────────
195    //
196    // Without a registered subscriber, `tracing::info!`'s macro expansion
197    // short-circuits field evaluation before the "is level enabled" check
198    // runs, so field-expression lines show as uncovered even though the
199    // surrounding branch executes. Each test below calls `with_tracing(|| {})`
200    // once as a bare statement (rather than wrapping the whole test body) to
201    // install the shared `AlwaysOnSubscriber` (see `crate::test_support`) as
202    // the process-wide default before the rest of the test runs.
203
204    // ─── format_size ───────────────────────────────────────────────────────
205
206    #[test]
207    fn format_size_bytes() {
208        assert_eq!(format_size(0), "0 B");
209        assert_eq!(format_size(512), "512 B");
210        assert_eq!(format_size(1023), "1023 B");
211    }
212
213    #[test]
214    fn format_size_kilobytes() {
215        assert_eq!(format_size(1024), "1.0 KB");
216        assert_eq!(format_size(2048), "2.0 KB");
217        assert_eq!(format_size(1536), "1.5 KB");
218    }
219
220    #[test]
221    fn format_size_megabytes() {
222        assert_eq!(format_size(1024 * 1024), "1.0 MB");
223        assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB");
224    }
225
226    // ─── count_files ───────────────────────────────────────────────────────
227
228    #[test]
229    fn count_files_empty_dir() {
230        let dir = tempfile::tempdir().unwrap();
231        assert_eq!(count_files(dir.path()), 0);
232    }
233
234    #[test]
235    fn count_files_with_files() {
236        let dir = tempfile::tempdir().unwrap();
237        std::fs::write(dir.path().join("a.txt"), "a").unwrap();
238        std::fs::write(dir.path().join("b.txt"), "b").unwrap();
239        assert_eq!(count_files(dir.path()), 2);
240    }
241
242    #[test]
243    fn count_files_nested() {
244        let dir = tempfile::tempdir().unwrap();
245        std::fs::write(dir.path().join("top.txt"), "t").unwrap();
246        std::fs::create_dir_all(dir.path().join("sub")).unwrap();
247        std::fs::write(dir.path().join("sub/nested.txt"), "n").unwrap();
248        assert_eq!(count_files(dir.path()), 2);
249    }
250
251    #[test]
252    fn count_files_non_directory_returns_zero() {
253        let dir = tempfile::tempdir().unwrap();
254        let file = dir.path().join("a.txt");
255        std::fs::write(&file, "hello").unwrap();
256        assert_eq!(count_files(&file), 0);
257    }
258
259    #[test]
260    fn count_files_read_dir_error_returns_zero() {
261        // An injected `read_dir` that fails on an existing directory exercises
262        // the "read_dir errored -> keep the count so far" arm deterministically
263        // on every platform.
264        let dir = tempfile::tempdir().unwrap();
265        let result = count_files_with(dir.path(), &|_| {
266            Err(std::io::Error::other("simulated read_dir failure"))
267        });
268        assert_eq!(result, 0);
269    }
270
271    #[test]
272    fn count_path_neither_file_nor_dir_counts_zero() {
273        // A nonexistent path is neither a file nor a directory, so `count_path`
274        // contributes 0 - covering the `else` arm on every platform.
275        let dir = tempfile::tempdir().unwrap();
276        let missing = dir.path().join("does-not-exist");
277        assert_eq!(count_path(&missing, &real_read_dir), 0);
278    }
279
280    // ─── find_manifest ─────────────────────────────────────────────────────
281
282    #[test]
283    fn find_manifest_in_directory() {
284        let dir = tempfile::tempdir().unwrap();
285        let manifest = dir.path().join("agent.leviath");
286        std::fs::write(&manifest, "name = \"test\"").unwrap();
287        let result = find_manifest(dir.path());
288        assert!(result.is_ok());
289        assert_eq!(result.unwrap(), manifest);
290    }
291
292    #[test]
293    fn find_manifest_direct_file() {
294        let dir = tempfile::tempdir().unwrap();
295        let manifest = dir.path().join("agent.leviath");
296        std::fs::write(&manifest, "name = \"test\"").unwrap();
297        let result = find_manifest(&manifest);
298        assert!(result.is_ok());
299        assert_eq!(result.unwrap(), manifest);
300    }
301
302    #[test]
303    fn find_manifest_not_found_errors() {
304        let dir = tempfile::tempdir().unwrap();
305        let result = find_manifest(dir.path());
306        assert!(result.is_err());
307        assert!(result.unwrap_err().to_string().contains("agent.leviath"));
308    }
309
310    // ─── find_manifest_with_cwd ────────────────────────────────────────────
311
312    #[test]
313    fn find_manifest_with_cwd_finds_in_directory() {
314        let dir = tempfile::tempdir().unwrap();
315        let manifest = dir.path().join("agent.leviath");
316        std::fs::write(&manifest, "name = \"test\"").unwrap();
317        let cwd = tempfile::tempdir().unwrap();
318        let result = find_manifest_with_cwd(dir.path(), cwd.path());
319        assert_eq!(result.unwrap(), manifest);
320    }
321
322    #[test]
323    fn find_manifest_with_cwd_finds_direct_file() {
324        let dir = tempfile::tempdir().unwrap();
325        let manifest = dir.path().join("agent.leviath");
326        std::fs::write(&manifest, "name = \"test\"").unwrap();
327        let cwd = tempfile::tempdir().unwrap();
328        let result = find_manifest_with_cwd(&manifest, cwd.path());
329        assert_eq!(result.unwrap(), manifest);
330    }
331
332    #[test]
333    fn find_manifest_with_cwd_falls_back_to_cwd() {
334        let empty_dir = tempfile::tempdir().unwrap();
335        let cwd_dir = tempfile::tempdir().unwrap();
336        let cwd_manifest = cwd_dir.path().join("agent.leviath");
337        std::fs::write(&cwd_manifest, "name = \"test\"").unwrap();
338        let result = find_manifest_with_cwd(empty_dir.path(), cwd_dir.path());
339        assert_eq!(result.unwrap(), cwd_manifest);
340    }
341
342    #[test]
343    fn find_manifest_with_cwd_errors_when_not_found() {
344        let empty_project = tempfile::tempdir().unwrap();
345        let empty_cwd = tempfile::tempdir().unwrap();
346        let result = find_manifest_with_cwd(empty_project.path(), empty_cwd.path());
347        assert!(result.is_err());
348        assert!(result.unwrap_err().to_string().contains("agent.leviath"));
349    }
350
351    // ─── output path determination ─────────────────────────────────────────
352
353    #[test]
354    fn output_path_from_args() {
355        let output_path =
356            determine_output_path(Some("my-output.leviath-bundle"), "my-agent", "1.0.0");
357        assert_eq!(output_path, PathBuf::from("my-output.leviath-bundle"));
358    }
359
360    #[test]
361    fn output_path_default() {
362        let output_path = determine_output_path(None, "my-agent", "1.0.0");
363        assert_eq!(output_path, PathBuf::from("my-agent-1.0.0.leviath-bundle"));
364    }
365
366    // ─── format_size edge cases ───────────────────────────────────────────
367
368    #[test]
369    fn format_size_boundary_kb() {
370        assert_eq!(format_size(1023), "1023 B");
371        assert_eq!(format_size(1024), "1.0 KB");
372    }
373
374    #[test]
375    fn format_size_boundary_mb() {
376        assert_eq!(format_size(1024 * 1024 - 1), "1024.0 KB");
377        assert_eq!(format_size(1024 * 1024), "1.0 MB");
378    }
379
380    #[test]
381    fn format_size_fractional_kb() {
382        assert_eq!(format_size(1536), "1.5 KB");
383        assert_eq!(format_size(2560), "2.5 KB");
384    }
385
386    // ─── count_files edge cases ───────────────────────────────────────────
387
388    #[test]
389    fn count_files_nested_multiple_levels() {
390        let dir = tempfile::tempdir().unwrap();
391        std::fs::create_dir_all(dir.path().join("a/b/c")).unwrap();
392        std::fs::write(dir.path().join("root.txt"), "r").unwrap();
393        std::fs::write(dir.path().join("a/level1.txt"), "1").unwrap();
394        std::fs::write(dir.path().join("a/b/level2.txt"), "2").unwrap();
395        std::fs::write(dir.path().join("a/b/c/level3.txt"), "3").unwrap();
396        assert_eq!(count_files(dir.path()), 4);
397    }
398
399    // ─── find_manifest edge cases ─────────────────────────────────────────
400
401    #[test]
402    fn find_manifest_nonexistent_path_errors() {
403        let result = find_manifest(Path::new("/tmp/nonexistent-leviath-test-dir"));
404        assert!(result.is_err());
405    }
406
407    // ─── output path generation ───────────────────────────────────────────
408
409    #[test]
410    fn output_path_with_special_chars() {
411        let output_path = determine_output_path(None, "my-agent", "1.0.0-beta.1");
412        assert_eq!(
413            output_path,
414            PathBuf::from("my-agent-1.0.0-beta.1.leviath-bundle")
415        );
416    }
417
418    // ─── execute ─────────────────────────────────────────────────────────
419
420    fn make_project_dir(with_scripts: bool, with_tests: bool) -> tempfile::TempDir {
421        let dir = tempfile::tempdir().unwrap();
422        std::fs::write(
423            dir.path().join("agent.leviath"),
424            "[agent]\nname = \"packed-agent\"\nversion = \"1.0.0\"\ndescription = \"d\"\n",
425        )
426        .unwrap();
427        if with_scripts {
428            std::fs::create_dir_all(dir.path().join("scripts")).unwrap();
429            std::fs::write(dir.path().join("scripts/run.sh"), "#!/bin/sh\n").unwrap();
430        }
431        if with_tests {
432            std::fs::create_dir_all(dir.path().join("tests")).unwrap();
433            std::fs::write(dir.path().join("tests/test1.txt"), "test").unwrap();
434        }
435        dir
436    }
437
438    #[tokio::test]
439    async fn execute_packs_project_to_explicit_output() {
440        with_tracing(|| {});
441        let project = make_project_dir(false, false);
442        let output_dir = tempfile::tempdir().unwrap();
443        let output_path = output_dir.path().join("out.leviath-bundle");
444        let args = PackArgs {
445            path: Some(project.path().to_str().unwrap().to_string()),
446            output: Some(output_path.to_str().unwrap().to_string()),
447        };
448        execute(args).await.unwrap();
449        assert!(output_path.exists());
450        assert!(std::fs::metadata(&output_path).unwrap().len() > 0);
451    }
452
453    #[tokio::test]
454    async fn execute_with_scripts_and_tests_dirs() {
455        with_tracing(|| {});
456        let project = make_project_dir(true, true);
457        let output_dir = tempfile::tempdir().unwrap();
458        let output_path = output_dir.path().join("out.leviath-bundle");
459        let args = PackArgs {
460            path: Some(project.path().to_str().unwrap().to_string()),
461            output: Some(output_path.to_str().unwrap().to_string()),
462        };
463        execute(args).await.unwrap();
464        assert!(output_path.exists());
465    }
466
467    #[tokio::test]
468    async fn execute_missing_manifest_errors() {
469        with_tracing(|| {});
470        let project = tempfile::tempdir().unwrap();
471        let output_dir = tempfile::tempdir().unwrap();
472        let output_path = output_dir.path().join("out.leviath-bundle");
473        let args = PackArgs {
474            path: Some(project.path().to_str().unwrap().to_string()),
475            output: Some(output_path.to_str().unwrap().to_string()),
476        };
477        let err = execute(args).await.unwrap_err();
478        assert!(err.to_string().contains("Could not find agent.leviath"));
479    }
480
481    #[tokio::test]
482    async fn execute_unwritable_output_path_errors() {
483        with_tracing(|| {});
484        let project = make_project_dir(false, false);
485        let output_path = project
486            .path()
487            .join("nonexistent-subdir")
488            .join("out.leviath-bundle");
489        let args = PackArgs {
490            path: Some(project.path().to_str().unwrap().to_string()),
491            output: Some(output_path.to_str().unwrap().to_string()),
492        };
493        let err = execute(args).await.unwrap_err();
494        assert!(err.to_string().contains("Failed to write bundle"));
495    }
496
497    #[tokio::test]
498    async fn execute_with_path_none_falls_back_to_dot() {
499        // args.path = None triggers the unwrap_or_else closure on line 21.
500        with_tracing(|| {});
501        let args = PackArgs {
502            path: None,
503            output: None,
504        };
505        let err = execute(args).await.unwrap_err();
506        assert!(err.to_string().contains("agent.leviath"));
507    }
508
509    #[tokio::test]
510    async fn execute_invalid_manifest_toml_errors() {
511        // Manifest exists but is invalid TOML - covers parse_manifest ? on line 30.
512        with_tracing(|| {});
513        let project = tempfile::tempdir().unwrap();
514        std::fs::write(project.path().join("agent.leviath"), "not valid toml ][").unwrap();
515        let output_dir = tempfile::tempdir().unwrap();
516        let output_path = output_dir.path().join("out.leviath-bundle");
517        let args = PackArgs {
518            path: Some(project.path().to_str().unwrap().to_string()),
519            output: Some(output_path.to_str().unwrap().to_string()),
520        };
521        execute(args).await.unwrap_err();
522    }
523
524    #[tokio::test]
525    async fn execute_unreadable_manifest_errors() {
526        // `agent.leviath` exists but is a *directory*: `find_manifest` returns
527        // it (exists() passes), then `read_to_string` fails on every platform,
528        // covering the "Failed to read manifest" map_err arm.
529        with_tracing(|| {});
530        let project = tempfile::tempdir().unwrap();
531        std::fs::create_dir_all(project.path().join("agent.leviath")).unwrap();
532        let output_dir = tempfile::tempdir().unwrap();
533        let output_path = output_dir.path().join("out.leviath-bundle");
534        let args = PackArgs {
535            path: Some(project.path().to_str().unwrap().to_string()),
536            output: Some(output_path.to_str().unwrap().to_string()),
537        };
538        let e = execute(args).await.unwrap_err();
539        assert!(e.to_string().contains("Failed to read manifest"));
540    }
541
542    #[tokio::test]
543    async fn execute_bundle_error_propagated() {
544        // Inject a bundling op that fails, covering the `bundle(project_dir)?`
545        // error arm on every platform.
546        with_tracing(|| {});
547        let project = make_project_dir(false, false);
548        let output_dir = tempfile::tempdir().unwrap();
549        let output_path = output_dir.path().join("out.leviath-bundle");
550        let args = PackArgs {
551            path: Some(project.path().to_str().unwrap().to_string()),
552            output: Some(output_path.to_str().unwrap().to_string()),
553        };
554        let result =
555            execute_with_bundle(args, &|_| Err(anyhow::anyhow!("simulated bundle failure"))).await;
556        let e = result.unwrap_err();
557        assert!(e.to_string().contains("simulated bundle failure"));
558    }
559}