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 mut counter: usize = 0;
let id = next_id(&mut counter);
let children = walk_children(&loader, &workspace, &root_manifest, &mut counter);
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 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,
workspace: &Path,
parent: &PackManifest,
counter: &mut usize,
) -> Vec<LsNode> {
let mut out = Vec::with_capacity(parent.children.len());
for child in &parent.children {
let dest = workspace.join(child.effective_path());
out.push(load_child_node(loader, workspace, child, &dest, counter));
}
out
}
fn load_child_node(
loader: &FsPackLoader,
workspace: &Path,
child: &ChildRef,
dest: &Path,
counter: &mut usize,
) -> LsNode {
match loader.load(dest) {
Ok(manifest) => loaded_node(loader, workspace, &manifest, dest, counter, false),
Err(TreeError::ManifestNotFound(_)) if dest_has_git_repo(dest) => {
let manifest = synthesize_plain_git_manifest(child);
loaded_node(loader, workspace, &manifest, dest, counter, true)
}
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,
workspace: &Path,
manifest: &PackManifest,
dest: &Path,
counter: &mut usize,
synthetic: bool,
) -> LsNode {
let id = next_id(counter);
let children = walk_children(loader, workspace, manifest, counter);
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);
}
}