use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::pack::{ChildRef, PackManifest, PackType};
use super::error::TreeError;
use super::loader::{FsPackLoader, PackLoader};
use super::walker::{dest_has_git_repo, synthesize_plain_git_manifest};
#[derive(Debug, Clone, Serialize)]
pub struct LsTree {
pub workspace: String,
pub tree: Vec<LsNode>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LsNode {
pub id: usize,
pub name: String,
pub path: String,
#[serde(rename = "type")]
pub pack_type: String,
pub synthetic: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub unsynced: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<LsNodeError>,
pub children: Vec<LsNode>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LsNodeError {
pub kind: String,
pub message: String,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
!*b
}
pub fn build_ls_tree(pack_root: &Path) -> Result<LsTree, String> {
let loader = FsPackLoader::new();
let root_manifest = loader.load(pack_root).map_err(|e| format!("{e}"))?;
let workspace = workspace_dir_for(pack_root);
let synthetic_index = build_synthetic_index(&workspace);
let mut counter: usize = 0;
let id = next_id(&mut counter);
let children =
walk_children(&loader, &workspace, &root_manifest, &mut counter, &synthetic_index);
Ok(LsTree {
workspace: workspace.display().to_string(),
tree: vec![LsNode {
id,
name: root_manifest.name.clone(),
path: pack_root.display().to_string(),
pack_type: root_manifest.r#type.as_str().to_string(),
synthetic: false,
unsynced: false,
error: None,
children,
}],
})
}
fn build_synthetic_index(workspace: &Path) -> HashMap<(PathBuf, String), bool> {
let mut idx = HashMap::new();
populate_synthetic_index(workspace, &mut idx);
idx
}
fn populate_synthetic_index(meta_dir: &Path, idx: &mut HashMap<(PathBuf, String), bool>) {
if let Ok(entries) = crate::lockfile::read_meta_lockfile(meta_dir) {
for entry in &entries {
idx.insert((meta_dir.to_path_buf(), entry.path.clone()), entry.synthetic);
}
}
let manifest_path = meta_dir.join(".grex").join("pack.yaml");
let raw = match std::fs::read_to_string(&manifest_path) {
Ok(s) => s,
Err(_) => return,
};
let manifest = match crate::pack::parse(&raw) {
Ok(m) => m,
Err(_) => return,
};
for child in &manifest.children {
let segment = child.path.clone().unwrap_or_else(|| child.effective_path());
let child_meta = meta_dir.join(&segment);
if child_meta.join(".grex").join("pack.yaml").is_file() {
populate_synthetic_index(&child_meta, idx);
}
}
}
fn workspace_dir_for(pack_root: &Path) -> PathBuf {
if has_yaml_extension(pack_root) {
pack_root
.parent()
.and_then(Path::parent)
.map_or_else(|| pack_root.to_path_buf(), Path::to_path_buf)
} else {
pack_root.to_path_buf()
}
}
fn has_yaml_extension(path: &Path) -> bool {
matches!(path.extension().and_then(|e| e.to_str()), Some("yaml" | "yml"))
}
fn walk_children(
loader: &FsPackLoader,
current_meta: &Path,
parent: &PackManifest,
counter: &mut usize,
synthetic_index: &HashMap<(PathBuf, String), bool>,
) -> Vec<LsNode> {
let mut out = Vec::with_capacity(parent.children.len());
for child in &parent.children {
let segment = child.effective_path();
let dest = current_meta.join(&segment);
let lock_synthetic =
synthetic_index.get(&(current_meta.to_path_buf(), segment.clone())).copied();
out.push(load_child_node(loader, child, &dest, counter, synthetic_index, lock_synthetic));
}
out
}
fn load_child_node(
loader: &FsPackLoader,
child: &ChildRef,
dest: &Path,
counter: &mut usize,
synthetic_index: &HashMap<(PathBuf, String), bool>,
lock_synthetic: Option<bool>,
) -> LsNode {
match loader.load(dest) {
Ok(manifest) => {
let synthetic = lock_synthetic.unwrap_or(false);
loaded_node(loader, &manifest, dest, counter, synthetic, synthetic_index)
}
Err(TreeError::ManifestNotFound(_)) if dest_has_git_repo(dest) => {
let manifest = synthesize_plain_git_manifest(child);
loaded_node(loader, &manifest, dest, counter, true, synthetic_index)
}
Err(TreeError::ManifestNotFound(_)) => unsynced_node(child, dest, counter),
Err(e @ TreeError::ManifestParse { .. }) => errored_node(child, dest, counter, "parse", &e),
Err(e @ TreeError::ManifestRead(_)) => errored_node(child, dest, counter, "read", &e),
Err(e) => errored_node(child, dest, counter, "other", &e),
}
}
fn loaded_node(
loader: &FsPackLoader,
manifest: &PackManifest,
dest: &Path,
counter: &mut usize,
synthetic: bool,
synthetic_index: &HashMap<(PathBuf, String), bool>,
) -> LsNode {
let id = next_id(counter);
let children = walk_children(loader, dest, manifest, counter, synthetic_index);
LsNode {
id,
name: manifest.name.clone(),
path: dest.display().to_string(),
pack_type: manifest.r#type.as_str().to_string(),
synthetic,
unsynced: false,
error: None,
children,
}
}
fn unsynced_node(child: &ChildRef, dest: &Path, counter: &mut usize) -> LsNode {
let id = next_id(counter);
LsNode {
id,
name: child.effective_path(),
path: dest.display().to_string(),
pack_type: PackType::Scripted.as_str().to_string(),
synthetic: false,
unsynced: true,
error: None,
children: Vec::new(),
}
}
fn errored_node(
child: &ChildRef,
dest: &Path,
counter: &mut usize,
kind: &str,
err: &TreeError,
) -> LsNode {
let message = format!("{err}");
eprintln!("grex ls: {}: {message}", child.effective_path());
let id = next_id(counter);
LsNode {
id,
name: child.effective_path(),
path: dest.display().to_string(),
pack_type: PackType::Scripted.as_str().to_string(),
synthetic: false,
unsynced: false,
error: Some(LsNodeError { kind: kind.to_string(), message }),
children: Vec::new(),
}
}
fn next_id(counter: &mut usize) -> usize {
let id = *counter;
*counter += 1;
id
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn build_ls_tree_emits_root_for_meta_pack() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".grex")).unwrap();
fs::write(root.join(".grex/pack.yaml"), "schema_version: \"1\"\nname: rootp\ntype: meta\n")
.unwrap();
let tree = build_ls_tree(root).expect("root manifest loads");
assert_eq!(tree.tree.len(), 1);
let node = &tree.tree[0];
assert_eq!(node.name, "rootp");
assert_eq!(node.pack_type, "meta");
assert!(!node.synthetic);
assert!(!node.unsynced);
assert!(node.error.is_none());
assert!(node.children.is_empty());
}
#[test]
fn build_ls_tree_surfaces_synthetic_plain_git_child() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".grex")).unwrap();
fs::write(
root.join(".grex/pack.yaml"),
"schema_version: \"1\"\nname: rootp\ntype: meta\nchildren:\n - url: file:///dev/null\n path: alpha\n",
)
.unwrap();
fs::create_dir_all(root.join("alpha/.git")).unwrap();
let tree = build_ls_tree(root).expect("root manifest loads");
let root_node = &tree.tree[0];
assert_eq!(root_node.children.len(), 1);
let child = &root_node.children[0];
assert!(child.synthetic, "plain-git child must be flagged synthetic");
assert_eq!(child.pack_type, "scripted");
assert_eq!(child.name, "alpha");
assert!(!child.unsynced);
assert!(child.error.is_none());
assert!(child.children.is_empty());
}
#[test]
fn build_ls_tree_returns_error_for_missing_manifest() {
let dir = tempdir().unwrap();
let err = build_ls_tree(dir.path()).expect_err("missing manifest is fatal");
assert!(!err.is_empty(), "error string must be human-readable");
}
#[test]
fn build_ls_tree_surfaces_unsynced_child_placeholder() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".grex")).unwrap();
fs::write(
root.join(".grex/pack.yaml"),
"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",
)
.unwrap();
let tree = build_ls_tree(root).expect("root manifest loads");
let root_node = &tree.tree[0];
assert_eq!(root_node.children.len(), 2, "both declared children must appear");
for (idx, expected) in ["alpha", "beta"].iter().enumerate() {
let child = &root_node.children[idx];
assert_eq!(child.name, *expected);
assert!(!child.synthetic);
assert!(child.unsynced, "unsynced placeholder expected for `{expected}`");
assert!(child.error.is_none());
}
}
#[test]
fn build_ls_tree_surfaces_parse_error_on_corrupt_child_yaml() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".grex")).unwrap();
fs::write(
root.join(".grex/pack.yaml"),
"schema_version: \"1\"\nname: rootp\ntype: meta\nchildren:\n - url: file:///dev/null\n path: corrupt\n",
)
.unwrap();
fs::create_dir_all(root.join("corrupt/.grex")).unwrap();
fs::write(root.join("corrupt/.grex/pack.yaml"), "::: not yaml ::: : :\n").unwrap();
let tree = build_ls_tree(root).expect("root manifest loads");
let root_node = &tree.tree[0];
assert_eq!(root_node.children.len(), 1);
let child = &root_node.children[0];
let err = child.error.as_ref().expect("parse-error child must carry error envelope");
assert_eq!(err.kind, "parse");
assert!(!err.message.is_empty());
assert!(!child.synthetic);
assert!(!child.unsynced);
}
use crate::lockfile::{write_meta_lockfile, LockEntry};
use chrono::{TimeZone, Utc};
fn ts_for_test() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 4, 29, 10, 0, 0).unwrap()
}
fn entry_with_path(id: &str, path: &str, synthetic: bool) -> LockEntry {
let mut e = LockEntry::new(id, "deadbeef", "main", ts_for_test(), "h", "1");
e.path = path.into();
e.synthetic = synthetic;
e
}
#[test]
fn test_ls_renders_v1_2_0_nested_layout() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".grex")).unwrap();
fs::write(
root.join(".grex/pack.yaml"),
"schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n - url: file:///dev/null\n path: alpha\n",
)
.unwrap();
fs::create_dir_all(root.join("alpha/.grex")).unwrap();
fs::write(
root.join("alpha/.grex/pack.yaml"),
"schema_version: \"1\"\nname: alpha\ntype: meta\nchildren:\n - url: file:///dev/null\n path: gamma\n",
)
.unwrap();
fs::create_dir_all(root.join("alpha/gamma/.grex")).unwrap();
fs::write(
root.join("alpha/gamma/.grex/pack.yaml"),
"schema_version: \"1\"\nname: gamma\ntype: declarative\n",
)
.unwrap();
let tree = build_ls_tree(root).expect("root manifest loads");
let root_node = &tree.tree[0];
assert_eq!(root_node.name, "root");
assert_eq!(root_node.children.len(), 1);
let alpha = &root_node.children[0];
assert_eq!(alpha.name, "alpha");
assert_eq!(alpha.pack_type, "meta");
assert_eq!(alpha.children.len(), 1, "nested meta must surface its grandchild");
let gamma = &alpha.children[0];
assert_eq!(gamma.name, "gamma");
assert_eq!(gamma.pack_type, "declarative");
assert!(!gamma.synthetic);
}
#[test]
fn test_ls_renders_legacy_synthetic_with_tilde_glyph() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".grex")).unwrap();
fs::write(
root.join(".grex/pack.yaml"),
"schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n - url: file:///dev/null\n path: legacy\n",
)
.unwrap();
fs::create_dir_all(root.join("legacy/.grex")).unwrap();
fs::write(
root.join("legacy/.grex/pack.yaml"),
"schema_version: \"1\"\nname: legacy\ntype: scripted\n",
)
.unwrap();
write_meta_lockfile(root, &[entry_with_path("legacy", "legacy", true)]).unwrap();
let tree = build_ls_tree(root).expect("root manifest loads");
let child = &tree.tree[0].children[0];
assert_eq!(child.name, "legacy");
assert!(
child.synthetic,
"legacy lockentry with synthetic=true must drive the ~ glyph in render layer",
);
}
#[test]
fn test_ls_v1_2_0_entry_no_glyph() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".grex")).unwrap();
fs::write(
root.join(".grex/pack.yaml"),
"schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n - url: file:///dev/null\n path: fresh\n",
)
.unwrap();
fs::create_dir_all(root.join("fresh/.grex")).unwrap();
fs::write(
root.join("fresh/.grex/pack.yaml"),
"schema_version: \"1\"\nname: fresh\ntype: scripted\n",
)
.unwrap();
write_meta_lockfile(root, &[entry_with_path("fresh", "fresh", false)]).unwrap();
let tree = build_ls_tree(root).expect("root manifest loads");
let child = &tree.tree[0].children[0];
assert_eq!(child.name, "fresh");
assert!(!child.synthetic, "v1.2.0 entry (synthetic=false) must not carry the ~ glyph");
}
#[test]
fn test_ls_uses_read_lockfile_tree() {
let dir = tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".grex")).unwrap();
fs::write(
root.join(".grex/pack.yaml"),
"schema_version: \"1\"\nname: root\ntype: meta\nchildren:\n - url: file:///dev/null\n path: alpha\n",
)
.unwrap();
write_meta_lockfile(root, &[entry_with_path("alpha", "alpha", true)]).unwrap();
fs::create_dir_all(root.join("alpha/.grex")).unwrap();
fs::write(
root.join("alpha/.grex/pack.yaml"),
"schema_version: \"1\"\nname: alpha\ntype: meta\nchildren:\n - url: file:///dev/null\n path: gamma\n",
)
.unwrap();
write_meta_lockfile(&root.join("alpha"), &[entry_with_path("gamma", "gamma", false)])
.unwrap();
fs::create_dir_all(root.join("alpha/gamma/.grex")).unwrap();
fs::write(
root.join("alpha/gamma/.grex/pack.yaml"),
"schema_version: \"1\"\nname: gamma\ntype: declarative\n",
)
.unwrap();
let tree = build_ls_tree(root).expect("root manifest loads");
let alpha = &tree.tree[0].children[0];
assert_eq!(alpha.name, "alpha");
assert!(alpha.synthetic, "root's lockfile flags alpha synthetic");
assert_eq!(alpha.children.len(), 1);
let gamma = &alpha.children[0];
assert_eq!(gamma.name, "gamma");
assert!(!gamma.synthetic, "alpha's lockfile leaves gamma fresh (synthetic=false)");
}
}