1use clap::Args;
4use leviath_package::AgentBundler;
5use std::path::{Path, PathBuf};
6
7use leviath_core::manifest::parse_manifest;
8
9#[derive(Args)]
11pub struct PackArgs {
12 #[arg(value_name = "PATH")]
14 pub path: Option<String>,
15
16 #[arg(short, long)]
18 pub output: Option<String>,
19}
20
21pub async fn execute(args: PackArgs) -> anyhow::Result<()> {
23 execute_with_bundle(args, &|dir| AgentBundler::new().bundle(dir)).await
24}
25
26async fn execute_with_bundle(
35 args: PackArgs,
36 bundle: &dyn Fn(&Path) -> anyhow::Result<Vec<u8>>,
37) -> anyhow::Result<()> {
38 let path = args.path.unwrap_or_else(|| ".".to_string());
39 let project_path = Path::new(&path);
40
41 tracing::info!("Packing agent");
42
43 let manifest_path = find_manifest(project_path)?;
45 let manifest_content = std::fs::read_to_string(&manifest_path)
46 .map_err(|e| anyhow::anyhow!("Failed to read manifest: {}", e))?;
47 let blueprint = parse_manifest(&manifest_content)?;
48
49 println!("Packing agent: {} v{}", blueprint.name, blueprint.version);
50
51 let output_path =
53 determine_output_path(args.output.as_deref(), &blueprint.name, &blueprint.version);
54
55 let project_dir = manifest_path.parent().unwrap_or(Path::new("."));
57
58 let data = bundle(project_dir)?;
59 let bundle_size = data.len();
60
61 std::fs::write(&output_path, &data).map_err(|e| {
62 anyhow::anyhow!(
63 "Failed to write bundle to '{}': {}",
64 output_path.display(),
65 e
66 )
67 })?;
68
69 println!("Bundle written to: {}", output_path.display());
71 println!("Bundle size: {}", format_size(bundle_size));
72
73 println!("\nContents:");
75 let file_count = count_files(project_dir);
76 println!(" {} files bundled", file_count);
77 println!(" Manifest: agent.leviath");
78
79 let scripts_dir = project_dir.join("scripts");
80 if scripts_dir.exists() {
81 let script_count = count_files(&scripts_dir);
82 println!(" Scripts: {} files", script_count);
83 }
84
85 let tests_dir = project_dir.join("tests");
86 if tests_dir.exists() {
87 let test_count = count_files(&tests_dir);
88 println!(" Tests: {} files", test_count);
89 }
90
91 println!("\nDone! Install with: lev add {}", output_path.display());
92
93 Ok(())
94}
95
96fn determine_output_path(output: Option<&str>, name: &str, version: &str) -> PathBuf {
98 match output {
99 Some(out) => PathBuf::from(out),
100 None => PathBuf::from(format!("{}-{}.leviath-bundle", name, version)),
101 }
102}
103
104fn find_manifest(project_path: &Path) -> anyhow::Result<PathBuf> {
105 find_manifest_with_cwd(project_path, &std::env::current_dir().unwrap_or_default())
106}
107
108fn find_manifest_with_cwd(project_path: &Path, cwd: &Path) -> anyhow::Result<PathBuf> {
109 if project_path.is_file()
110 && project_path.file_name() == Some(std::ffi::OsStr::new("agent.leviath"))
111 {
112 return Ok(project_path.to_path_buf());
113 }
114
115 if project_path.is_dir() {
116 let manifest = project_path.join("agent.leviath");
117 if manifest.exists() {
118 return Ok(manifest);
119 }
120 }
121
122 let current_manifest = cwd.join("agent.leviath");
123 if current_manifest.exists() {
124 return Ok(current_manifest);
125 }
126
127 anyhow::bail!(
128 "Could not find agent.leviath in {} or current directory",
129 project_path.display()
130 )
131}
132
133fn format_size(bytes: usize) -> String {
134 if bytes < 1024 {
135 format!("{} B", bytes)
136 } else if bytes < 1024 * 1024 {
137 format!("{:.1} KB", bytes as f64 / 1024.0)
138 } else {
139 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
140 }
141}
142
143fn real_read_dir(p: &Path) -> std::io::Result<std::fs::ReadDir> {
147 std::fs::read_dir(p)
148}
149
150fn count_files(dir: &Path) -> usize {
152 count_files_with(dir, &real_read_dir)
153}
154
155fn count_files_with(
162 dir: &Path,
163 read_dir: &dyn Fn(&Path) -> std::io::Result<std::fs::ReadDir>,
164) -> usize {
165 let mut count = 0;
166 if dir.is_dir()
167 && let Ok(entries) = read_dir(dir)
168 {
169 for entry in entries.filter_map(|e| e.ok()) {
170 count += count_path(&entry.path(), read_dir);
171 }
172 }
173 count
174}
175
176fn count_path(path: &Path, read_dir: &dyn Fn(&Path) -> std::io::Result<std::fs::ReadDir>) -> usize {
182 if path.is_file() {
183 1
184 } else if path.is_dir() {
185 count_files_with(path, read_dir)
186 } else {
187 0
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use crate::test_support::with_tracing;
195
196 #[test]
209 fn format_size_bytes() {
210 assert_eq!(format_size(0), "0 B");
211 assert_eq!(format_size(512), "512 B");
212 assert_eq!(format_size(1023), "1023 B");
213 }
214
215 #[test]
216 fn format_size_kilobytes() {
217 assert_eq!(format_size(1024), "1.0 KB");
218 assert_eq!(format_size(2048), "2.0 KB");
219 assert_eq!(format_size(1536), "1.5 KB");
220 }
221
222 #[test]
223 fn format_size_megabytes() {
224 assert_eq!(format_size(1024 * 1024), "1.0 MB");
225 assert_eq!(format_size(5 * 1024 * 1024), "5.0 MB");
226 }
227
228 #[test]
231 fn count_files_empty_dir() {
232 let dir = tempfile::tempdir().unwrap();
233 assert_eq!(count_files(dir.path()), 0);
234 }
235
236 #[test]
237 fn count_files_with_files() {
238 let dir = tempfile::tempdir().unwrap();
239 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
240 std::fs::write(dir.path().join("b.txt"), "b").unwrap();
241 assert_eq!(count_files(dir.path()), 2);
242 }
243
244 #[test]
245 fn count_files_nested() {
246 let dir = tempfile::tempdir().unwrap();
247 std::fs::write(dir.path().join("top.txt"), "t").unwrap();
248 std::fs::create_dir_all(dir.path().join("sub")).unwrap();
249 std::fs::write(dir.path().join("sub/nested.txt"), "n").unwrap();
250 assert_eq!(count_files(dir.path()), 2);
251 }
252
253 #[test]
254 fn count_files_non_directory_returns_zero() {
255 let dir = tempfile::tempdir().unwrap();
256 let file = dir.path().join("a.txt");
257 std::fs::write(&file, "hello").unwrap();
258 assert_eq!(count_files(&file), 0);
259 }
260
261 #[test]
262 fn count_files_read_dir_error_returns_zero() {
263 let dir = tempfile::tempdir().unwrap();
267 let result = count_files_with(dir.path(), &|_| {
268 Err(std::io::Error::other("simulated read_dir failure"))
269 });
270 assert_eq!(result, 0);
271 }
272
273 #[test]
274 fn count_path_neither_file_nor_dir_counts_zero() {
275 let dir = tempfile::tempdir().unwrap();
278 let missing = dir.path().join("does-not-exist");
279 assert_eq!(count_path(&missing, &real_read_dir), 0);
280 }
281
282 #[test]
285 fn find_manifest_in_directory() {
286 let dir = tempfile::tempdir().unwrap();
287 let manifest = dir.path().join("agent.leviath");
288 std::fs::write(&manifest, "name = \"test\"").unwrap();
289 let result = find_manifest(dir.path());
290 assert!(result.is_ok());
291 assert_eq!(result.unwrap(), manifest);
292 }
293
294 #[test]
295 fn find_manifest_direct_file() {
296 let dir = tempfile::tempdir().unwrap();
297 let manifest = dir.path().join("agent.leviath");
298 std::fs::write(&manifest, "name = \"test\"").unwrap();
299 let result = find_manifest(&manifest);
300 assert!(result.is_ok());
301 assert_eq!(result.unwrap(), manifest);
302 }
303
304 #[test]
305 fn find_manifest_not_found_errors() {
306 let dir = tempfile::tempdir().unwrap();
307 let result = find_manifest(dir.path());
308 assert!(result.is_err());
309 assert!(result.unwrap_err().to_string().contains("agent.leviath"));
310 }
311
312 #[test]
315 fn find_manifest_with_cwd_finds_in_directory() {
316 let dir = tempfile::tempdir().unwrap();
317 let manifest = dir.path().join("agent.leviath");
318 std::fs::write(&manifest, "name = \"test\"").unwrap();
319 let cwd = tempfile::tempdir().unwrap();
320 let result = find_manifest_with_cwd(dir.path(), cwd.path());
321 assert_eq!(result.unwrap(), manifest);
322 }
323
324 #[test]
325 fn find_manifest_with_cwd_finds_direct_file() {
326 let dir = tempfile::tempdir().unwrap();
327 let manifest = dir.path().join("agent.leviath");
328 std::fs::write(&manifest, "name = \"test\"").unwrap();
329 let cwd = tempfile::tempdir().unwrap();
330 let result = find_manifest_with_cwd(&manifest, cwd.path());
331 assert_eq!(result.unwrap(), manifest);
332 }
333
334 #[test]
335 fn find_manifest_with_cwd_falls_back_to_cwd() {
336 let empty_dir = tempfile::tempdir().unwrap();
337 let cwd_dir = tempfile::tempdir().unwrap();
338 let cwd_manifest = cwd_dir.path().join("agent.leviath");
339 std::fs::write(&cwd_manifest, "name = \"test\"").unwrap();
340 let result = find_manifest_with_cwd(empty_dir.path(), cwd_dir.path());
341 assert_eq!(result.unwrap(), cwd_manifest);
342 }
343
344 #[test]
345 fn find_manifest_with_cwd_errors_when_not_found() {
346 let empty_project = tempfile::tempdir().unwrap();
347 let empty_cwd = tempfile::tempdir().unwrap();
348 let result = find_manifest_with_cwd(empty_project.path(), empty_cwd.path());
349 assert!(result.is_err());
350 assert!(result.unwrap_err().to_string().contains("agent.leviath"));
351 }
352
353 #[test]
356 fn output_path_from_args() {
357 let output_path =
358 determine_output_path(Some("my-output.leviath-bundle"), "my-agent", "1.0.0");
359 assert_eq!(output_path, PathBuf::from("my-output.leviath-bundle"));
360 }
361
362 #[test]
363 fn output_path_default() {
364 let output_path = determine_output_path(None, "my-agent", "1.0.0");
365 assert_eq!(output_path, PathBuf::from("my-agent-1.0.0.leviath-bundle"));
366 }
367
368 #[test]
371 fn format_size_boundary_kb() {
372 assert_eq!(format_size(1023), "1023 B");
373 assert_eq!(format_size(1024), "1.0 KB");
374 }
375
376 #[test]
377 fn format_size_boundary_mb() {
378 assert_eq!(format_size(1024 * 1024 - 1), "1024.0 KB");
379 assert_eq!(format_size(1024 * 1024), "1.0 MB");
380 }
381
382 #[test]
383 fn format_size_fractional_kb() {
384 assert_eq!(format_size(1536), "1.5 KB");
385 assert_eq!(format_size(2560), "2.5 KB");
386 }
387
388 #[test]
391 fn count_files_nested_multiple_levels() {
392 let dir = tempfile::tempdir().unwrap();
393 std::fs::create_dir_all(dir.path().join("a/b/c")).unwrap();
394 std::fs::write(dir.path().join("root.txt"), "r").unwrap();
395 std::fs::write(dir.path().join("a/level1.txt"), "1").unwrap();
396 std::fs::write(dir.path().join("a/b/level2.txt"), "2").unwrap();
397 std::fs::write(dir.path().join("a/b/c/level3.txt"), "3").unwrap();
398 assert_eq!(count_files(dir.path()), 4);
399 }
400
401 #[test]
404 fn find_manifest_nonexistent_path_errors() {
405 let result = find_manifest(Path::new("/tmp/nonexistent-leviath-test-dir"));
406 assert!(result.is_err());
407 }
408
409 #[test]
412 fn output_path_with_special_chars() {
413 let output_path = determine_output_path(None, "my-agent", "1.0.0-beta.1");
414 assert_eq!(
415 output_path,
416 PathBuf::from("my-agent-1.0.0-beta.1.leviath-bundle")
417 );
418 }
419
420 fn make_project_dir(with_scripts: bool, with_tests: bool) -> tempfile::TempDir {
423 let dir = tempfile::tempdir().unwrap();
424 std::fs::write(
425 dir.path().join("agent.leviath"),
426 "[agent]\nname = \"packed-agent\"\nversion = \"1.0.0\"\ndescription = \"d\"\n",
427 )
428 .unwrap();
429 if with_scripts {
430 std::fs::create_dir_all(dir.path().join("scripts")).unwrap();
431 std::fs::write(dir.path().join("scripts/run.sh"), "#!/bin/sh\n").unwrap();
432 }
433 if with_tests {
434 std::fs::create_dir_all(dir.path().join("tests")).unwrap();
435 std::fs::write(dir.path().join("tests/test1.txt"), "test").unwrap();
436 }
437 dir
438 }
439
440 #[tokio::test]
441 async fn execute_packs_project_to_explicit_output() {
442 with_tracing(|| {});
443 let project = make_project_dir(false, false);
444 let output_dir = tempfile::tempdir().unwrap();
445 let output_path = output_dir.path().join("out.leviath-bundle");
446 let args = PackArgs {
447 path: Some(project.path().to_str().unwrap().to_string()),
448 output: Some(output_path.to_str().unwrap().to_string()),
449 };
450 execute(args).await.unwrap();
451 assert!(output_path.exists());
452 assert!(std::fs::metadata(&output_path).unwrap().len() > 0);
453 }
454
455 #[tokio::test]
456 async fn execute_with_scripts_and_tests_dirs() {
457 with_tracing(|| {});
458 let project = make_project_dir(true, true);
459 let output_dir = tempfile::tempdir().unwrap();
460 let output_path = output_dir.path().join("out.leviath-bundle");
461 let args = PackArgs {
462 path: Some(project.path().to_str().unwrap().to_string()),
463 output: Some(output_path.to_str().unwrap().to_string()),
464 };
465 execute(args).await.unwrap();
466 assert!(output_path.exists());
467 }
468
469 #[tokio::test]
470 async fn execute_missing_manifest_errors() {
471 with_tracing(|| {});
472 let project = tempfile::tempdir().unwrap();
473 let output_dir = tempfile::tempdir().unwrap();
474 let output_path = output_dir.path().join("out.leviath-bundle");
475 let args = PackArgs {
476 path: Some(project.path().to_str().unwrap().to_string()),
477 output: Some(output_path.to_str().unwrap().to_string()),
478 };
479 let err = execute(args).await.unwrap_err();
480 assert!(err.to_string().contains("Could not find agent.leviath"));
481 }
482
483 #[tokio::test]
484 async fn execute_unwritable_output_path_errors() {
485 with_tracing(|| {});
486 let project = make_project_dir(false, false);
487 let output_path = project
488 .path()
489 .join("nonexistent-subdir")
490 .join("out.leviath-bundle");
491 let args = PackArgs {
492 path: Some(project.path().to_str().unwrap().to_string()),
493 output: Some(output_path.to_str().unwrap().to_string()),
494 };
495 let err = execute(args).await.unwrap_err();
496 assert!(err.to_string().contains("Failed to write bundle"));
497 }
498
499 #[tokio::test]
500 async fn execute_with_path_none_falls_back_to_dot() {
501 with_tracing(|| {});
503 let args = PackArgs {
504 path: None,
505 output: None,
506 };
507 let err = execute(args).await.unwrap_err();
508 assert!(err.to_string().contains("agent.leviath"));
509 }
510
511 #[tokio::test]
512 async fn execute_invalid_manifest_toml_errors() {
513 with_tracing(|| {});
515 let project = tempfile::tempdir().unwrap();
516 std::fs::write(project.path().join("agent.leviath"), "not valid toml ][").unwrap();
517 let output_dir = tempfile::tempdir().unwrap();
518 let output_path = output_dir.path().join("out.leviath-bundle");
519 let args = PackArgs {
520 path: Some(project.path().to_str().unwrap().to_string()),
521 output: Some(output_path.to_str().unwrap().to_string()),
522 };
523 execute(args).await.unwrap_err();
524 }
525
526 #[tokio::test]
527 async fn execute_unreadable_manifest_errors() {
528 with_tracing(|| {});
532 let project = tempfile::tempdir().unwrap();
533 std::fs::create_dir_all(project.path().join("agent.leviath")).unwrap();
534 let output_dir = tempfile::tempdir().unwrap();
535 let output_path = output_dir.path().join("out.leviath-bundle");
536 let args = PackArgs {
537 path: Some(project.path().to_str().unwrap().to_string()),
538 output: Some(output_path.to_str().unwrap().to_string()),
539 };
540 let e = execute(args).await.unwrap_err();
541 assert!(e.to_string().contains("Failed to read manifest"));
542 }
543
544 #[tokio::test]
545 async fn execute_bundle_error_propagated() {
546 with_tracing(|| {});
549 let project = make_project_dir(false, false);
550 let output_dir = tempfile::tempdir().unwrap();
551 let output_path = output_dir.path().join("out.leviath-bundle");
552 let args = PackArgs {
553 path: Some(project.path().to_str().unwrap().to_string()),
554 output: Some(output_path.to_str().unwrap().to_string()),
555 };
556 let result =
557 execute_with_bundle(args, &|_| Err(anyhow::anyhow!("simulated bundle failure"))).await;
558 let e = result.unwrap_err();
559 assert!(e.to_string().contains("simulated bundle failure"));
560 }
561}