use crate::prelude::*;
use axum::{
extract::{Query, State},
http::StatusCode,
Json,
};
use std::{
collections::HashMap,
fs,
path::{Component, Path, PathBuf},
};
use super::{fleet::workspace_root, sessions::ErrorResponse};
use crate::fleet::{config::GitModules, error::SuperGitError};
use crate::git_backend::FileStatusKind;
use crate::web::github;
use crate::web::handlers::fleet::RepoDetailedStatus;
use crate::web::state::WebState;
use crate::workspace_state::WorkspaceState;
use axum::extract::State as AxumState;
use axum::response::sse::{Event, KeepAlive, Sse};
use futures::stream::Stream;
use std::convert::Infallible;
#[derive(Debug, Deserialize)]
pub struct WorkspacePathQuery {
#[serde(default)]
path: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceUpdateRequest {
#[serde(default)]
pub path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceUpdateResponse {
pub path: String,
pub version: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceEntryKind {
File,
Directory,
#[serde(rename = "error")]
Error,
}
#[derive(Debug, Serialize, Clone)]
pub struct WorkspaceSubmoduleInfo {
pub name: String,
pub version: Option<String>,
pub github_repo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceEntry {
pub path: String,
pub name: String,
pub kind: WorkspaceEntryKind,
pub hidden: bool,
pub submodule: Option<WorkspaceSubmoduleInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_status: Option<FileStatusKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_status: Option<RepoDetailedStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceTreeResponse {
pub root: String,
pub path: String,
pub entries: Vec<WorkspaceEntry>,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceFileResponse {
pub root: String,
pub path: String,
pub content: String,
}
pub async fn list_tree(
State(state): State<WebState>,
Query(query): Query<WorkspacePathQuery>,
) -> Result<Json<WorkspaceTreeResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root()?;
let relative = sanitize_relative_path(query.path.as_deref().unwrap_or(""))?;
let target = resolve_workspace_path(&root, &relative)?;
let git_snapshot: HashMap<String, RepoDetailedStatus> =
state.git_status_snapshot.read().await.clone();
if !target.exists() {
return Ok(Json(WorkspaceTreeResponse {
root: root.display().to_string(),
path: display_relative_path(&relative),
entries: vec![WorkspaceEntry {
path: display_relative_path(&relative),
name: relative
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
kind: WorkspaceEntryKind::Error,
hidden: false,
submodule: None,
git_status: None,
repo_status: None,
repo_path: None,
}],
}));
}
let (root, relative, entries) = tokio::task::spawn_blocking(move || {
let metadata = fs::metadata(&target).map_err(internal_error)?;
if !metadata.is_dir() {
return Err(bad_request(format!(
"workspace path '{}' is not a directory",
display_relative_path(&relative)
)));
}
let mut raw_entries = fs::read_dir(&target)
.map_err(internal_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(internal_error)?;
raw_entries.sort_by(|left, right| {
let left_kind = left.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let right_kind = right.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
match right_kind.cmp(&left_kind) {
std::cmp::Ordering::Equal => left.file_name().cmp(&right.file_name()),
ordering => ordering,
}
});
let visible_directory_paths = raw_entries
.iter()
.filter_map(|entry| {
let file_type = entry.file_type().ok()?;
if !file_type.is_dir() {
return None;
}
let name = entry.file_name().to_string_lossy().into_owned();
Some(join_relative_path(&relative, &name))
})
.collect::<Vec<_>>();
let is_root = relative.as_os_str().is_empty();
let submodules = if is_root {
load_workspace_submodules(&root, &visible_directory_paths)
} else {
HashMap::new()
};
let uninitialized_submodules = if is_root {
load_uninitialized_submodules(&root, &visible_directory_paths)
} else {
vec![]
};
let relative_str = display_relative_path(&relative);
let owning_repo: Option<&RepoDetailedStatus> = git_snapshot
.values()
.find(|r| {
if r.is_superproject {
relative_str.is_empty()
} else {
relative_str == r.path || relative_str.starts_with(&format!("{}/", r.path))
}
});
let (file_status_map, repo_path_prefix): (HashMap<String, FileStatusKind>, &str) =
if let Some(repo) = owning_repo {
let prefix = if repo.is_superproject { "" } else { &repo.path };
let map = repo
.files
.iter()
.map(|f| {
let ws_path = if prefix.is_empty() {
f.path.clone()
} else {
format!("{prefix}/{}", f.path)
};
(ws_path, f.status)
})
.collect();
(map, prefix)
} else {
(HashMap::new(), "")
};
let mut entries = raw_entries
.into_iter()
.map(|entry| {
let file_type = entry.file_type().map_err(internal_error)?;
let file_name = entry.file_name().to_string_lossy().into_owned();
let entry_relative = join_relative_path(&relative, &file_name);
let submodule = if file_type.is_dir() {
submodules.get(&entry_relative).cloned()
} else {
None
};
let (git_status, repo_status, repo_path) = if file_type.is_dir() {
if submodule.is_some() {
let rs = git_snapshot.get(&entry_relative).cloned();
(None, rs, None)
} else {
let dir_status =
aggregate_dir_status(&entry_relative, &file_status_map);
(dir_status, None, None)
}
} else {
let fs = file_status_map.get(&entry_relative).copied();
let rp = if repo_path_prefix.is_empty() {
None
} else {
Some(repo_path_prefix.to_string())
};
(fs, None, rp)
};
Ok(WorkspaceEntry {
path: entry_relative.clone(),
name: submodule
.as_ref()
.map(|s| s.name.clone())
.unwrap_or(file_name),
kind: if file_type.is_dir() {
WorkspaceEntryKind::Directory
} else {
WorkspaceEntryKind::File
},
hidden: is_hidden_name(&entry_relative),
submodule,
git_status,
repo_status,
repo_path,
})
})
.collect::<Result<Vec<_>, _>>()?;
for sub in &uninitialized_submodules {
let rs = git_snapshot.get(&sub.path).cloned();
entries.push(WorkspaceEntry {
path: sub.path.clone(),
name: sub.name.clone(),
kind: WorkspaceEntryKind::Directory,
hidden: is_hidden_name(&sub.path),
submodule: Some(WorkspaceSubmoduleInfo {
name: sub.name.clone(),
version: None,
github_repo: sub.github_repo.clone(),
error: Some("submodule not initialized".to_string()),
}),
git_status: None,
repo_status: rs,
repo_path: None,
});
}
entries.sort_by(compare_workspace_entries);
Ok((root, relative, entries))
})
.await
.map_err(|e| internal_error(format!("list_tree task panicked: {e}")))??;
Ok(Json(WorkspaceTreeResponse {
root: root.display().to_string(),
path: display_relative_path(&relative),
entries,
}))
}
fn aggregate_dir_status(
dir_path: &str,
file_status_map: &HashMap<String, FileStatusKind>,
) -> Option<FileStatusKind> {
let prefix = format!("{dir_path}/");
let mut worst: Option<FileStatusKind> = None;
for (path, &status) in file_status_map {
if !path.starts_with(&prefix) {
continue;
}
if status == FileStatusKind::Ignored {
continue;
}
worst = Some(match worst {
None => status,
Some(current) => higher_priority_status(current, status),
});
if worst == Some(FileStatusKind::Conflict) {
return worst;
}
}
worst
}
fn higher_priority_status(a: FileStatusKind, b: FileStatusKind) -> FileStatusKind {
if status_priority(b) > status_priority(a) { b } else { a }
}
fn status_priority(s: FileStatusKind) -> u8 {
match s {
FileStatusKind::Conflict => 6,
FileStatusKind::Deleted => 5,
FileStatusKind::Modified => 4,
FileStatusKind::Staged => 3,
FileStatusKind::Renamed => 2,
FileStatusKind::Untracked => 1,
FileStatusKind::Ignored => 0,
}
}
pub async fn workspace_watch(
State(state): State<WebState>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let mut rx = state.git_status_tx.subscribe();
let stream = async_stream::stream! {
yield Ok(Event::default().event("connected").data("{}"));
loop {
match rx.recv().await {
Ok(repo) => {
let paths = affected_tree_paths(&repo.path);
if let Ok(json) = serde_json::to_string(&paths) {
yield Ok(Event::default().event("changed").data(json));
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
let paths: Vec<String> = vec!["".to_string()];
if let Ok(json) = serde_json::to_string(&paths) {
yield Ok(Event::default().event("changed").data(json));
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
};
Sse::new(stream).keep_alive(KeepAlive::default())
}
fn affected_tree_paths(repo_path: &str) -> Vec<String> {
if repo_path == "." {
return vec!["".to_string()];
}
let mut paths = vec!["".to_string()];
if let Some(parent) = std::path::Path::new(repo_path).parent() {
let p = parent.to_string_lossy();
if !p.is_empty() && p != "." {
paths.push(p.into_owned());
}
}
paths.dedup();
paths
}
fn load_workspace_submodules(
root: &Path,
visible_directory_paths: &[String],
) -> HashMap<String, WorkspaceSubmoduleInfo> {
let gitmodules = match GitModules::parse(root) {
Ok(gm) => gm,
Err(SuperGitError::NoSubmodules) => return HashMap::new(),
Err(e) => {
tracing::warn!("Failed to parse .gitmodules: {e}");
return HashMap::new();
}
};
let visible_directory_paths = visible_directory_paths
.iter()
.cloned()
.collect::<std::collections::HashSet<_>>();
let mut submodules = HashMap::with_capacity(gitmodules.submodules.len());
for submodule in gitmodules.submodules {
let relative_path = submodule.path.to_string_lossy().into_owned();
if !visible_directory_paths.contains(&relative_path) {
continue;
}
let submodule_root = root.join(&submodule.path);
let version = crate::state::read_version_from_path(&submodule_root)
.map(|version| version.full_version)
.ok();
let github_repo = github::parse_github_url(&submodule.url)
.map(|(owner, repo)| format!("{owner}/{repo}"));
let (name, error) = match read_submodule_project_name(&submodule_root) {
Some(name) => (name, None),
None => (submodule.name, None),
};
submodules.insert(
relative_path,
WorkspaceSubmoduleInfo {
name,
version,
github_repo,
error,
},
);
}
submodules
}
struct UninitializedSubmodule {
path: String,
name: String,
github_repo: Option<String>,
}
fn load_uninitialized_submodules(
root: &Path,
existing_directory_paths: &[String],
) -> Vec<UninitializedSubmodule> {
let gitmodules = match GitModules::parse(root) {
Ok(gm) => gm,
Err(SuperGitError::NoSubmodules) => return Vec::new(),
Err(_) => return Vec::new(),
};
let existing: std::collections::HashSet<_> = existing_directory_paths.iter().collect();
let mut result = Vec::new();
for submodule in gitmodules.submodules {
let relative_path = submodule.path.to_string_lossy().into_owned();
if existing.contains(&relative_path) {
continue;
}
let github_repo = github::parse_github_url(&submodule.url)
.map(|(owner, repo)| format!("{owner}/{repo}"));
result.push(UninitializedSubmodule {
path: relative_path,
name: submodule.name,
github_repo,
});
}
result
}
fn read_submodule_project_name(submodule_root: &Path) -> Option<String> {
let state_file = submodule_root.join(".wsb").join("state.json");
if !state_file.exists() {
return None;
}
let content = fs::read_to_string(&state_file).ok()?;
let state = serde_json::from_str::<WorkspaceState>(&content).ok()?;
state.project_name.and_then(|name| {
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
pub async fn read_file(
State(_state): State<WebState>,
Query(query): Query<WorkspacePathQuery>,
) -> Result<Json<WorkspaceFileResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root()?;
let requested = query.path.as_deref().unwrap_or("").trim();
if requested.is_empty() {
return Err(bad_request("workspace file path is required".to_string()));
}
let relative = sanitize_relative_path(requested)?;
let target = resolve_workspace_path(&root, &relative)?;
let (root, relative, content) = tokio::task::spawn_blocking(move || {
let metadata = fs::metadata(&target).map_err(internal_error)?;
if !metadata.is_file() {
return Err(bad_request(format!(
"workspace path '{}' is not a file",
display_relative_path(&relative)
)));
}
let bytes = fs::read(&target).map_err(internal_error)?;
let content = String::from_utf8(bytes).map_err(|_| {
bad_request(format!(
"workspace file '{}' is not valid UTF-8 text",
display_relative_path(&relative)
))
})?;
Ok((root, relative, content))
})
.await
.map_err(|e| internal_error(format!("read_file task panicked: {e}")))??;
Ok(Json(WorkspaceFileResponse {
root: root.display().to_string(),
path: display_relative_path(&relative),
content,
}))
}
pub async fn run_update(
AxumState(_state): AxumState<WebState>,
Json(body): Json<WorkspaceUpdateRequest>,
) -> Result<Json<WorkspaceUpdateResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root()?;
let relative = sanitize_relative_path(body.path.as_deref().unwrap_or(""))?;
validate_update_target(&root, &relative)?;
let target = resolve_workspace_path(&root, &relative)?;
let metadata = fs::metadata(&target).map_err(internal_error)?;
if !metadata.is_dir() {
return Err(bad_request(format!(
"workspace path '{}' is not a directory",
display_relative_path(&relative)
)));
}
let display_path = display_relative_path(&relative);
let version = crate::workspace_update::run_workspace_update_async(&target)
.await
.map_err(internal_error)?
.version;
Ok(Json(WorkspaceUpdateResponse {
path: display_path,
version,
}))
}
fn sanitize_relative_path(raw: &str) -> Result<PathBuf, (StatusCode, Json<ErrorResponse>)> {
let path = Path::new(raw);
if path.is_absolute() {
return Err(bad_request(format!(
"workspace path '{}' must be relative to the workspace root",
raw
)));
}
let mut relative = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(part) => relative.push(part),
Component::ParentDir => {
return Err(bad_request(format!(
"workspace path '{}' must not traverse outside the workspace root",
raw
)))
}
Component::RootDir | Component::Prefix(_) => {
return Err(bad_request(format!(
"workspace path '{}' must be relative to the workspace root",
raw
)))
}
}
}
Ok(relative)
}
fn validate_update_target(
root: &Path,
relative: &Path,
) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
if relative.as_os_str().is_empty() {
return Ok(());
}
let gitmodules = match GitModules::parse(root) {
Ok(gitmodules) => gitmodules,
Err(SuperGitError::NoSubmodules) => {
return Err(bad_request(format!(
"workspace path '{}' is not a submodule root",
relative.display()
)))
}
Err(error) => return Err(internal_error(error)),
};
let relative_str = relative.to_string_lossy();
if gitmodules
.submodules
.iter()
.any(|submodule| submodule.path == relative)
{
return Ok(());
}
Err(bad_request(format!(
"workspace path '{}' is not a submodule root",
relative_str
)))
}
fn resolve_workspace_path(
root: &Path,
relative: &Path,
) -> Result<PathBuf, (StatusCode, Json<ErrorResponse>)> {
let target = root.join(relative);
if !target.starts_with(root) {
return Err(bad_request(format!(
"workspace path '{}' resolved outside the workspace root",
display_relative_path(relative)
)));
}
Ok(target)
}
fn join_relative_path(base: &Path, name: &str) -> String {
if base.as_os_str().is_empty() {
name.to_string()
} else {
base.join(name).to_string_lossy().into_owned()
}
}
fn display_relative_path(path: &Path) -> String {
if path.as_os_str().is_empty() {
String::new()
} else {
path.to_string_lossy().into_owned()
}
}
fn compare_workspace_entries(left: &WorkspaceEntry, right: &WorkspaceEntry) -> std::cmp::Ordering {
let left_is_directory = left.kind == WorkspaceEntryKind::Directory;
let right_is_directory = right.kind == WorkspaceEntryKind::Directory;
match right_is_directory.cmp(&left_is_directory) {
std::cmp::Ordering::Equal => {
let left_is_submodule = left.submodule.is_some();
let right_is_submodule = right.submodule.is_some();
match right_is_submodule.cmp(&left_is_submodule) {
std::cmp::Ordering::Equal => left.name.cmp(&right.name),
ordering => ordering,
}
}
ordering => ordering,
}
}
fn is_hidden_name(path: &str) -> bool {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with('.'))
}
fn bad_request(error: String) -> (StatusCode, Json<ErrorResponse>) {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error }))
}
fn internal_error(error: impl ToString) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: error.to_string(),
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn sanitize_relative_path_rejects_parent_traversal() {
let error = sanitize_relative_path("../secret.txt").unwrap_err();
assert_eq!(error.0, StatusCode::BAD_REQUEST);
assert!(error.1.error.contains("must not traverse outside"));
}
#[test]
fn list_tree_sorts_directories_before_files() {
let temp = TempDir::new().unwrap();
fs::create_dir(temp.path().join("src")).unwrap();
fs::write(temp.path().join("Cargo.toml"), "wsb").unwrap();
fs::write(temp.path().join("README.md"), "docs").unwrap();
let mut entries = fs::read_dir(temp.path())
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
entries.sort_by(|left, right| {
let left_kind = left.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let right_kind = right.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
match right_kind.cmp(&left_kind) {
std::cmp::Ordering::Equal => left.file_name().cmp(&right.file_name()),
ordering => ordering,
}
});
let names = entries
.into_iter()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(names, vec!["src", "Cargo.toml", "README.md"]);
}
#[test]
fn resolve_workspace_path_stays_under_root() {
let temp = TempDir::new().unwrap();
let relative = match sanitize_relative_path("src/lib.rs") {
Ok(value) => value,
Err(_) => panic!("expected relative path to sanitize"),
};
let resolved = match resolve_workspace_path(temp.path(), &relative) {
Ok(value) => value,
Err(_) => panic!("expected workspace path to resolve under root"),
};
assert!(resolved.starts_with(temp.path()));
assert!(resolved.ends_with("src/lib.rs"));
}
#[test]
fn hidden_entries_are_detected_from_leaf_name() {
assert!(is_hidden_name(".git"));
assert!(is_hidden_name("dir/.env"));
assert!(!is_hidden_name("src/lib.rs"));
}
#[test]
fn load_workspace_submodules_reads_versions_from_version_txt() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join(".gitmodules"),
r#"[submodule "core"]
path = libs/core
url = https://github.com/example/core.git
"#,
)
.unwrap();
fs::create_dir_all(temp.path().join("libs/core")).unwrap();
fs::write(temp.path().join("libs/core/version.txt"), "0.414.56214\n").unwrap();
let submodules =
load_workspace_submodules(temp.path(), &[String::from("libs/core")]);
assert_eq!(submodules.len(), 1);
assert_eq!(submodules["libs/core"].name, "core");
assert_eq!(submodules["libs/core"].version.as_deref(), Some("0.414.56214"));
}
#[test]
fn load_workspace_submodules_omits_version_when_unavailable() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join(".gitmodules"),
r#"[submodule "thesis"]
path = thesis
url = https://github.com/example/thesis.git
"#,
)
.unwrap();
fs::create_dir_all(temp.path().join("thesis")).unwrap();
let submodules = load_workspace_submodules(temp.path(), &[String::from("thesis")]);
assert_eq!(submodules.len(), 1);
assert_eq!(submodules["thesis"].name, "thesis");
assert_eq!(submodules["thesis"].version, None);
}
#[test]
fn read_submodule_project_name_uses_workspace_state_when_present() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".wsb")).unwrap();
fs::write(
temp.path().join(".wsb/state.json"),
r#"{
"version": 1,
"project_root": "/tmp/example",
"project_name": "Capdag Core",
"tools": {},
"wstemplate_entries": [],
"completion_hint_shown": false
}"#,
)
.unwrap();
assert_eq!(
read_submodule_project_name(temp.path()).as_deref(),
Some("Capdag Core")
);
}
#[test]
fn compare_workspace_entries_places_submodules_before_other_nodes() {
let mut entries = vec![
WorkspaceEntry {
path: "README.md".to_string(),
name: "README.md".to_string(),
kind: WorkspaceEntryKind::File,
hidden: false,
submodule: None,
git_status: None,
repo_status: None,
repo_path: None,
},
WorkspaceEntry {
path: "src".to_string(),
name: "src".to_string(),
kind: WorkspaceEntryKind::Directory,
hidden: false,
submodule: None,
git_status: None,
repo_status: None,
repo_path: None,
},
WorkspaceEntry {
path: "capdag".to_string(),
name: "capdag".to_string(),
kind: WorkspaceEntryKind::Directory,
hidden: false,
submodule: Some(WorkspaceSubmoduleInfo {
name: "capdag".to_string(),
version: Some("0.414.56214".to_string()),
github_repo: None,
error: None,
}),
git_status: None,
repo_status: None,
repo_path: None,
},
];
entries.sort_by(compare_workspace_entries);
assert_eq!(
entries
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>(),
vec!["capdag", "src", "README.md"]
);
}
#[test]
fn load_workspace_submodules_returns_empty_for_non_root_path() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join(".gitmodules"),
r#"[submodule "child"]
path = child
url = https://github.com/example/child.git
"#,
)
.unwrap();
fs::create_dir_all(temp.path().join("child")).unwrap();
let submodule_visible_dirs = vec!["child/foo".to_string(), "child/src".to_string()];
let result = load_workspace_submodules(temp.path(), &submodule_visible_dirs);
assert!(
result.is_empty(),
"root .gitmodules must not inject submodules into subdirectory listings, got: {result:?}"
);
}
#[test]
fn load_uninitialized_submodules_only_reports_missing_root_children() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join(".gitmodules"),
r#"[submodule "sub"]
path = sub
url = https://github.com/example/sub.git
[submodule "initialized"]
path = initialized
url = https://github.com/example/initialized.git
"#,
)
.unwrap();
fs::create_dir_all(temp.path().join("initialized")).unwrap();
let root_visible_dirs = vec!["initialized".to_string()];
let result = load_uninitialized_submodules(temp.path(), &root_visible_dirs);
assert_eq!(result.len(), 1, "only 'sub' is uninitialized");
assert_eq!(result[0].path, "sub");
}
}