1use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31
32use serde::Serialize;
33
34use crate::pack::{ChildRef, PackManifest, PackType};
35
36use super::error::TreeError;
37use super::loader::{FsPackLoader, PackLoader};
38use super::walker::{dest_has_git_repo, synthesize_plain_git_manifest};
39
40#[derive(Debug, Clone, Serialize)]
48pub struct LsTree {
49 pub workspace: String,
53 pub tree: Vec<LsNode>,
55}
56
57#[derive(Debug, Clone, Serialize)]
69pub struct LsNode {
70 pub id: usize,
72 pub name: String,
75 pub path: String,
77 #[serde(rename = "type")]
81 pub pack_type: String,
82 pub synthetic: bool,
85 #[serde(default, skip_serializing_if = "is_false")]
92 pub unsynced: bool,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub error: Option<LsNodeError>,
98 pub children: Vec<LsNode>,
101}
102
103#[derive(Debug, Clone, Serialize)]
107pub struct LsNodeError {
108 pub kind: String,
113 pub message: String,
116}
117
118#[allow(clippy::trivially_copy_pass_by_ref)]
119fn is_false(b: &bool) -> bool {
120 !*b
121}
122
123pub fn build_ls_tree(pack_root: &Path) -> Result<LsTree, String> {
142 let loader = FsPackLoader::new();
143 let root_manifest = loader.load(pack_root).map_err(|e| format!("{e}"))?;
144 let workspace = workspace_dir_for(pack_root);
145 let synthetic_index = build_synthetic_index(&workspace);
155 let mut counter: usize = 0;
156 let id = next_id(&mut counter);
157 let children =
158 walk_children(&loader, &workspace, &root_manifest, &mut counter, &synthetic_index);
159 Ok(LsTree {
160 workspace: workspace.display().to_string(),
161 tree: vec![LsNode {
162 id,
163 name: root_manifest.name.clone(),
164 path: pack_root.display().to_string(),
165 pack_type: root_manifest.r#type.as_str().to_string(),
166 synthetic: false,
167 unsynced: false,
168 error: None,
169 children,
170 }],
171 })
172}
173
174fn build_synthetic_index(workspace: &Path) -> HashMap<(PathBuf, String), bool> {
181 let mut idx = HashMap::new();
182 populate_synthetic_index(workspace, &mut idx);
183 idx
184}
185
186fn populate_synthetic_index(meta_dir: &Path, idx: &mut HashMap<(PathBuf, String), bool>) {
190 if let Ok(entries) = crate::lockfile::read_meta_lockfile(meta_dir) {
191 for entry in &entries {
192 idx.insert((meta_dir.to_path_buf(), entry.path.clone()), entry.synthetic);
193 }
194 }
195 let manifest_path = meta_dir.join(".grex").join("pack.yaml");
197 let raw = match std::fs::read_to_string(&manifest_path) {
198 Ok(s) => s,
199 Err(_) => return,
200 };
201 let manifest = match crate::pack::parse(&raw) {
202 Ok(m) => m,
203 Err(_) => return,
204 };
205 for child in &manifest.children {
206 let segment = child.path.clone().unwrap_or_else(|| child.effective_path());
207 let child_meta = meta_dir.join(&segment);
208 if child_meta.join(".grex").join("pack.yaml").is_file() {
209 populate_synthetic_index(&child_meta, idx);
210 }
211 }
212}
213
214fn workspace_dir_for(pack_root: &Path) -> PathBuf {
219 if has_yaml_extension(pack_root) {
220 pack_root
222 .parent()
223 .and_then(Path::parent)
224 .map_or_else(|| pack_root.to_path_buf(), Path::to_path_buf)
225 } else {
226 pack_root.to_path_buf()
227 }
228}
229
230fn has_yaml_extension(path: &Path) -> bool {
231 matches!(path.extension().and_then(|e| e.to_str()), Some("yaml" | "yml"))
232}
233
234fn walk_children(
241 loader: &FsPackLoader,
242 current_meta: &Path,
243 parent: &PackManifest,
244 counter: &mut usize,
245 synthetic_index: &HashMap<(PathBuf, String), bool>,
246) -> Vec<LsNode> {
247 let mut out = Vec::with_capacity(parent.children.len());
248 for child in &parent.children {
249 let segment = child.effective_path();
250 let dest = current_meta.join(&segment);
251 let lock_synthetic =
255 synthetic_index.get(&(current_meta.to_path_buf(), segment.clone())).copied();
256 out.push(load_child_node(loader, child, &dest, counter, synthetic_index, lock_synthetic));
257 }
258 out
259}
260
261fn load_child_node(
267 loader: &FsPackLoader,
268 child: &ChildRef,
269 dest: &Path,
270 counter: &mut usize,
271 synthetic_index: &HashMap<(PathBuf, String), bool>,
272 lock_synthetic: Option<bool>,
273) -> LsNode {
274 match loader.load(dest) {
275 Ok(manifest) => {
276 let synthetic = lock_synthetic.unwrap_or(false);
281 loaded_node(loader, &manifest, dest, counter, synthetic, synthetic_index)
282 }
283 Err(TreeError::ManifestNotFound(_)) if dest_has_git_repo(dest) => {
284 let manifest = synthesize_plain_git_manifest(child);
285 loaded_node(loader, &manifest, dest, counter, true, synthetic_index)
286 }
287 Err(TreeError::ManifestNotFound(_)) => unsynced_node(child, dest, counter),
288 Err(e @ TreeError::ManifestParse { .. }) => errored_node(child, dest, counter, "parse", &e),
289 Err(e @ TreeError::ManifestRead(_)) => errored_node(child, dest, counter, "read", &e),
290 Err(e) => errored_node(child, dest, counter, "other", &e),
291 }
292}
293
294fn loaded_node(
300 loader: &FsPackLoader,
301 manifest: &PackManifest,
302 dest: &Path,
303 counter: &mut usize,
304 synthetic: bool,
305 synthetic_index: &HashMap<(PathBuf, String), bool>,
306) -> LsNode {
307 let id = next_id(counter);
308 let children = walk_children(loader, dest, manifest, counter, synthetic_index);
309 LsNode {
310 id,
311 name: manifest.name.clone(),
312 path: dest.display().to_string(),
313 pack_type: manifest.r#type.as_str().to_string(),
314 synthetic,
315 unsynced: false,
316 error: None,
317 children,
318 }
319}
320
321fn unsynced_node(child: &ChildRef, dest: &Path, counter: &mut usize) -> LsNode {
323 let id = next_id(counter);
324 LsNode {
325 id,
326 name: child.effective_path(),
327 path: dest.display().to_string(),
328 pack_type: PackType::Scripted.as_str().to_string(),
329 synthetic: false,
330 unsynced: true,
331 error: None,
332 children: Vec::new(),
333 }
334}
335
336fn errored_node(
341 child: &ChildRef,
342 dest: &Path,
343 counter: &mut usize,
344 kind: &str,
345 err: &TreeError,
346) -> LsNode {
347 let message = format!("{err}");
348 eprintln!("grex ls: {}: {message}", child.effective_path());
349 let id = next_id(counter);
350 LsNode {
351 id,
352 name: child.effective_path(),
353 path: dest.display().to_string(),
354 pack_type: PackType::Scripted.as_str().to_string(),
355 synthetic: false,
356 unsynced: false,
357 error: Some(LsNodeError { kind: kind.to_string(), message }),
358 children: Vec::new(),
359 }
360}
361
362fn next_id(counter: &mut usize) -> usize {
363 let id = *counter;
364 *counter += 1;
365 id
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use std::fs;
372 use tempfile::tempdir;
373
374 #[test]
375 fn build_ls_tree_emits_root_for_meta_pack() {
376 let dir = tempdir().unwrap();
377 let root = dir.path();
378 fs::create_dir_all(root.join(".grex")).unwrap();
379 fs::write(root.join(".grex/pack.yaml"), "schema_version: \"1\"\nname: rootp\ntype: meta\n")
380 .unwrap();
381
382 let tree = build_ls_tree(root).expect("root manifest loads");
383 assert_eq!(tree.tree.len(), 1);
384 let node = &tree.tree[0];
385 assert_eq!(node.name, "rootp");
386 assert_eq!(node.pack_type, "meta");
387 assert!(!node.synthetic);
388 assert!(!node.unsynced);
389 assert!(node.error.is_none());
390 assert!(node.children.is_empty());
391 }
392
393 #[test]
394 fn build_ls_tree_surfaces_synthetic_plain_git_child() {
395 let dir = tempdir().unwrap();
396 let root = dir.path();
397 fs::create_dir_all(root.join(".grex")).unwrap();
398 fs::write(
399 root.join(".grex/pack.yaml"),
400 "schema_version: \"1\"\nname: rootp\ntype: meta\nchildren:\n - url: file:///dev/null\n path: alpha\n",
401 )
402 .unwrap();
403 fs::create_dir_all(root.join("alpha/.git")).unwrap();
405
406 let tree = build_ls_tree(root).expect("root manifest loads");
407 let root_node = &tree.tree[0];
408 assert_eq!(root_node.children.len(), 1);
409 let child = &root_node.children[0];
410 assert!(child.synthetic, "plain-git child must be flagged synthetic");
411 assert_eq!(child.pack_type, "scripted");
412 assert_eq!(child.name, "alpha");
413 assert!(!child.unsynced);
414 assert!(child.error.is_none());
415 assert!(child.children.is_empty());
416 }
417
418 #[test]
419 fn build_ls_tree_returns_error_for_missing_manifest() {
420 let dir = tempdir().unwrap();
421 let err = build_ls_tree(dir.path()).expect_err("missing manifest is fatal");
422 assert!(!err.is_empty(), "error string must be human-readable");
423 }
424
425 #[test]
430 fn build_ls_tree_surfaces_unsynced_child_placeholder() {
431 let dir = tempdir().unwrap();
432 let root = dir.path();
433 fs::create_dir_all(root.join(".grex")).unwrap();
434 fs::write(
435 root.join(".grex/pack.yaml"),
436 "schema_version: \"1\"\nname: rootp\ntype: meta\nchildren:\n - url: file:///dev/null\n path: alpha\n - url: file:///dev/null\n path: beta\n",
437 )
438 .unwrap();
439 let tree = build_ls_tree(root).expect("root manifest loads");
442 let root_node = &tree.tree[0];
443 assert_eq!(root_node.children.len(), 2, "both declared children must appear");
444 for (idx, expected) in ["alpha", "beta"].iter().enumerate() {
445 let child = &root_node.children[idx];
446 assert_eq!(child.name, *expected);
447 assert!(!child.synthetic);
448 assert!(child.unsynced, "unsynced placeholder expected for `{expected}`");
449 assert!(child.error.is_none());
450 }
451 }
452
453 #[test]
458 fn build_ls_tree_surfaces_parse_error_on_corrupt_child_yaml() {
459 let dir = tempdir().unwrap();
460 let root = dir.path();
461 fs::create_dir_all(root.join(".grex")).unwrap();
462 fs::write(
463 root.join(".grex/pack.yaml"),
464 "schema_version: \"1\"\nname: rootp\ntype: meta\nchildren:\n - url: file:///dev/null\n path: corrupt\n",
465 )
466 .unwrap();
467 fs::create_dir_all(root.join("corrupt/.grex")).unwrap();
468 fs::write(root.join("corrupt/.grex/pack.yaml"), "::: not yaml ::: : :\n").unwrap();
470
471 let tree = build_ls_tree(root).expect("root manifest loads");
472 let root_node = &tree.tree[0];
473 assert_eq!(root_node.children.len(), 1);
474 let child = &root_node.children[0];
475 let err = child.error.as_ref().expect("parse-error child must carry error envelope");
476 assert_eq!(err.kind, "parse");
477 assert!(!err.message.is_empty());
478 assert!(!child.synthetic);
479 assert!(!child.unsynced);
480 }
481
482 use crate::lockfile::{write_meta_lockfile, LockEntry};
485 use chrono::{TimeZone, Utc};
486
487 fn ts_for_test() -> chrono::DateTime<Utc> {
488 Utc.with_ymd_and_hms(2026, 4, 29, 10, 0, 0).unwrap()
489 }
490
491 fn entry_with_path(id: &str, path: &str, synthetic: bool) -> LockEntry {
492 let mut e = LockEntry::new(id, "deadbeef", "main", ts_for_test(), "h", "1");
493 e.path = path.into();
494 e.synthetic = synthetic;
495 e
496 }
497
498 #[test]
503 fn test_ls_renders_v1_2_0_nested_layout() {
504 let dir = tempdir().unwrap();
505 let root = dir.path();
506 fs::create_dir_all(root.join(".grex")).unwrap();
507 fs::write(
508 root.join(".grex/pack.yaml"),
509 "schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n - url: file:///dev/null\n path: alpha\n",
510 )
511 .unwrap();
512 fs::create_dir_all(root.join("alpha/.grex")).unwrap();
514 fs::write(
515 root.join("alpha/.grex/pack.yaml"),
516 "schema_version: \"1\"\nname: alpha\ntype: meta\nchildren:\n - url: file:///dev/null\n path: gamma\n",
517 )
518 .unwrap();
519 fs::create_dir_all(root.join("alpha/gamma/.grex")).unwrap();
520 fs::write(
521 root.join("alpha/gamma/.grex/pack.yaml"),
522 "schema_version: \"1\"\nname: gamma\ntype: declarative\n",
523 )
524 .unwrap();
525
526 let tree = build_ls_tree(root).expect("root manifest loads");
527 let root_node = &tree.tree[0];
528 assert_eq!(root_node.name, "root");
529 assert_eq!(root_node.children.len(), 1);
530 let alpha = &root_node.children[0];
531 assert_eq!(alpha.name, "alpha");
532 assert_eq!(alpha.pack_type, "meta");
533 assert_eq!(alpha.children.len(), 1, "nested meta must surface its grandchild");
534 let gamma = &alpha.children[0];
535 assert_eq!(gamma.name, "gamma");
536 assert_eq!(gamma.pack_type, "declarative");
537 assert!(!gamma.synthetic);
538 }
539
540 #[test]
545 fn test_ls_renders_legacy_synthetic_with_tilde_glyph() {
546 let dir = tempdir().unwrap();
547 let root = dir.path();
548 fs::create_dir_all(root.join(".grex")).unwrap();
549 fs::write(
550 root.join(".grex/pack.yaml"),
551 "schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n - url: file:///dev/null\n path: legacy\n",
552 )
553 .unwrap();
554 fs::create_dir_all(root.join("legacy/.grex")).unwrap();
557 fs::write(
558 root.join("legacy/.grex/pack.yaml"),
559 "schema_version: \"1\"\nname: legacy\ntype: scripted\n",
560 )
561 .unwrap();
562 write_meta_lockfile(root, &[entry_with_path("legacy", "legacy", true)]).unwrap();
564
565 let tree = build_ls_tree(root).expect("root manifest loads");
566 let child = &tree.tree[0].children[0];
567 assert_eq!(child.name, "legacy");
568 assert!(
569 child.synthetic,
570 "legacy lockentry with synthetic=true must drive the ~ glyph in render layer",
571 );
572 }
573
574 #[test]
578 fn test_ls_v1_2_0_entry_no_glyph() {
579 let dir = tempdir().unwrap();
580 let root = dir.path();
581 fs::create_dir_all(root.join(".grex")).unwrap();
582 fs::write(
583 root.join(".grex/pack.yaml"),
584 "schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n - url: file:///dev/null\n path: fresh\n",
585 )
586 .unwrap();
587 fs::create_dir_all(root.join("fresh/.grex")).unwrap();
588 fs::write(
589 root.join("fresh/.grex/pack.yaml"),
590 "schema_version: \"1\"\nname: fresh\ntype: scripted\n",
591 )
592 .unwrap();
593 write_meta_lockfile(root, &[entry_with_path("fresh", "fresh", false)]).unwrap();
595
596 let tree = build_ls_tree(root).expect("root manifest loads");
597 let child = &tree.tree[0].children[0];
598 assert_eq!(child.name, "fresh");
599 assert!(!child.synthetic, "v1.2.0 entry (synthetic=false) must not carry the ~ glyph");
600 }
601
602 #[test]
607 fn test_ls_uses_read_lockfile_tree() {
608 let dir = tempdir().unwrap();
609 let root = dir.path();
610 fs::create_dir_all(root.join(".grex")).unwrap();
613 fs::write(
614 root.join(".grex/pack.yaml"),
615 "schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n - url: file:///dev/null\n path: alpha\n",
616 )
617 .unwrap();
618 write_meta_lockfile(root, &[entry_with_path("alpha", "alpha", true)]).unwrap();
620
621 fs::create_dir_all(root.join("alpha/.grex")).unwrap();
622 fs::write(
623 root.join("alpha/.grex/pack.yaml"),
624 "schema_version: \"1\"\nname: alpha\ntype: meta\nchildren:\n - url: file:///dev/null\n path: gamma\n",
625 )
626 .unwrap();
627 write_meta_lockfile(&root.join("alpha"), &[entry_with_path("gamma", "gamma", false)])
629 .unwrap();
630
631 fs::create_dir_all(root.join("alpha/gamma/.grex")).unwrap();
632 fs::write(
633 root.join("alpha/gamma/.grex/pack.yaml"),
634 "schema_version: \"1\"\nname: gamma\ntype: declarative\n",
635 )
636 .unwrap();
637
638 let tree = build_ls_tree(root).expect("root manifest loads");
639 let alpha = &tree.tree[0].children[0];
640 assert_eq!(alpha.name, "alpha");
641 assert!(alpha.synthetic, "root's lockfile flags alpha synthetic");
642 assert_eq!(alpha.children.len(), 1);
643 let gamma = &alpha.children[0];
644 assert_eq!(gamma.name, "gamma");
645 assert!(!gamma.synthetic, "alpha's lockfile leaves gamma fresh (synthetic=false)");
646 }
647}