use axum::{
extract::{Query, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
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::web::github;
use crate::web::state::WebState;
use crate::workspace_state::WorkspaceState;
use axum::extract::State as AxumState;
#[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,
}
#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
pub struct WorkspaceSubmoduleInfo {
pub name: String,
pub version: Option<String>,
pub github_repo: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceEntry {
pub path: String,
pub name: String,
pub kind: WorkspaceEntryKind,
pub hidden: bool,
pub submodule: Option<WorkspaceSubmoduleInfo>,
}
#[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 (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 submodules =
load_workspace_submodules(&root, &visible_directory_paths).map_err(internal_error)?;
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
};
Ok(WorkspaceEntry {
path: entry_relative.clone(),
name: submodule
.as_ref()
.map(|submodule| submodule.name.clone())
.unwrap_or(file_name),
kind: if file_type.is_dir() {
WorkspaceEntryKind::Directory
} else {
WorkspaceEntryKind::File
},
hidden: is_hidden_name(&entry_relative),
submodule,
})
})
.collect::<Result<Vec<_>, _>>()?;
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 load_workspace_submodules(
root: &Path,
visible_directory_paths: &[String],
) -> anyhow::Result<HashMap<String, WorkspaceSubmoduleInfo>> {
let gitmodules = match GitModules::parse(root) {
Ok(gitmodules) => gitmodules,
Err(SuperGitError::NoSubmodules) => return Ok(HashMap::new()),
Err(error) => return Err(error.into()),
};
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 version = crate::state::read_version_from_path(&root.join(&submodule.path))
.map(|version| version.full_version)
.ok();
let github_repo = github::parse_github_url(&submodule.url)
.map(|(owner, repo)| format!("{owner}/{repo}"));
submodules.insert(
relative_path,
WorkspaceSubmoduleInfo {
name: read_submodule_project_name(&root.join(&submodule.path))
.unwrap_or(submodule.name),
version,
github_repo,
},
);
}
Ok(submodules)
}
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")]).unwrap();
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")]).unwrap();
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,
},
WorkspaceEntry {
path: "src".to_string(),
name: "src".to_string(),
kind: WorkspaceEntryKind::Directory,
hidden: false,
submodule: 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,
}),
},
];
entries.sort_by(compare_workspace_entries);
assert_eq!(
entries
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>(),
vec!["capdag", "src", "README.md"]
);
}
}