feat: add profile source tree editor

This commit is contained in:
2026-07-09 17:22:57 +09:00
parent 7da14f1a8d
commit 81abfa630a
11 changed files with 1106 additions and 121 deletions
+487 -67
View File
@@ -14,6 +14,8 @@ use crate::{Error, Result};
const PROFILE_REGISTRY_RELATIVE_PATH: &str = ".yoi/profiles.toml";
const PROFILE_SOURCE_ROOT_RELATIVE_PATH: &str = ".yoi/profiles";
const PROFILE_SOURCE_TREE_ID: &str = "project";
const PROFILE_SOURCE_TREE_DISPLAY_ROOT: &str = "profiles";
const MAX_PROFILE_SOURCE_BYTES: u64 = 256 * 1024;
const BUILTIN_PROFILE_IDS: &[&str] = &[
"builtin:default",
@@ -63,6 +65,8 @@ pub struct ProfileSettingsResponse {
pub default_profile: Option<String>,
pub profiles: Vec<WorkspaceProfileSummary>,
pub sources: Vec<WorkspaceProfileSourceSummary>,
#[serde(default)]
pub source_trees: Vec<WorkspaceProfileSourceTreeSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
@@ -92,6 +96,45 @@ pub struct WorkspaceProfileSourceSummary {
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceTreeSummary {
pub source_tree_id: String,
pub label: String,
pub root_path: String,
pub kind: String,
pub editable: bool,
pub revision: String,
pub file_count: usize,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceTreeFileSummary {
pub path: String,
pub kind: String,
pub editable: bool,
pub revision: String,
pub size_bytes: u64,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceTreeResponse {
pub workspace_id: String,
pub tree: WorkspaceProfileSourceTreeSummary,
pub files: Vec<WorkspaceProfileSourceTreeFileSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceTreeFileResponse {
pub workspace_id: String,
pub source_tree_id: String,
pub file: WorkspaceProfileSourceTreeFileSummary,
pub content: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceProfileSourceDetailResponse {
pub workspace_id: String,
@@ -137,6 +180,28 @@ pub struct UpdateWorkspaceProfileSourceRequest {
pub revision: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WriteWorkspaceProfileTreeFileRequest {
pub path: String,
pub content: String,
#[serde(default)]
pub revision: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeleteWorkspaceProfileTreeFileRequest {
pub path: String,
pub revision: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReadWorkspaceProfileTreeFileQuery {
pub path: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeleteWorkspaceProfileSourceRequest {
@@ -355,6 +420,7 @@ pub fn load_profile_settings(workspace_id: &str, workspace_root: &Path) -> Profi
default_profile: registry.default,
profiles,
sources,
source_trees: vec![profile_source_tree_summary(workspace_root)],
diagnostics,
}
}
@@ -580,6 +646,115 @@ pub fn delete_profile_source(
})
}
pub fn read_profile_source_tree(
workspace_id: &str,
workspace_root: &Path,
source_tree_id: &str,
) -> Result<WorkspaceProfileSourceTreeResponse> {
ensure_profile_source_tree_id(source_tree_id)?;
let files = list_profile_tree_files(workspace_root)?;
Ok(WorkspaceProfileSourceTreeResponse {
workspace_id: workspace_id.to_string(),
tree: profile_source_tree_summary_with_count(workspace_root, files.len()),
files,
diagnostics: Vec::new(),
})
}
pub fn read_profile_tree_file(
workspace_id: &str,
workspace_root: &Path,
source_tree_id: &str,
query: ReadWorkspaceProfileTreeFileQuery,
) -> Result<WorkspaceProfileSourceTreeFileResponse> {
ensure_profile_source_tree_id(source_tree_id)?;
let relative_path = relative_source_path_for_virtual_path(&query.path)?;
let full = checked_source_path(workspace_root, &relative_path)?;
let metadata = source_metadata(&full)?;
if metadata.len() > MAX_PROFILE_SOURCE_BYTES {
return Err(profile_validation_error(
"profile_source_too_large",
"Profile source is too large for browser editing",
));
}
let content = fs::read_to_string(&full)?;
Ok(WorkspaceProfileSourceTreeFileResponse {
workspace_id: workspace_id.to_string(),
source_tree_id: source_tree_id.to_string(),
file: summarize_tree_file(&full, display_source_path(&relative_path)),
content,
diagnostics: Vec::new(),
})
}
pub fn write_profile_tree_file(
workspace_id: &str,
workspace_root: &Path,
source_tree_id: &str,
request: WriteWorkspaceProfileTreeFileRequest,
) -> Result<WorkspaceProfileSourceTreeFileResponse> {
ensure_profile_source_tree_id(source_tree_id)?;
if request.content.as_bytes().len() as u64 > MAX_PROFILE_SOURCE_BYTES {
return Err(profile_validation_error(
"profile_source_too_large",
"Profile source exceeds the browser editing size limit",
));
}
let relative_path = relative_source_path_for_virtual_path(&request.path)?;
let full = workspace_root.join(&relative_path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent)?;
}
let full = checked_source_path(workspace_root, &relative_path)?;
if let Some(expected) = request.revision.as_deref() {
ensure_revision(&full, expected, "profile_source_revision_conflict")?;
} else if full.exists() {
return Err(profile_validation_error(
"profile_source_revision_required",
"Existing profile source edits require a revision token",
));
}
validate_tree_content(workspace_root, &relative_path, &request.content)?;
fs::write(&full, &request.content)?;
Ok(WorkspaceProfileSourceTreeFileResponse {
workspace_id: workspace_id.to_string(),
source_tree_id: source_tree_id.to_string(),
file: summarize_tree_file(&full, display_source_path(&relative_path)),
content: request.content,
diagnostics: vec![diagnostic(
"profile_source_file_written",
DiagnosticSeverity::Info,
"Profile source file was written through the source tree API.",
)],
})
}
pub fn delete_profile_tree_file(
workspace_id: &str,
workspace_root: &Path,
source_tree_id: &str,
request: DeleteWorkspaceProfileTreeFileRequest,
) -> Result<WorkspaceProfileSourceTreeResponse> {
ensure_profile_source_tree_id(source_tree_id)?;
let relative_path = relative_source_path_for_virtual_path(&request.path)?;
let full = checked_source_path(workspace_root, &relative_path)?;
ensure_revision(&full, &request.revision, "profile_source_revision_conflict")?;
let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
if project_entries(&registry, &mut Vec::new())
.iter()
.any(|entry| entry.relative_path == relative_path)
{
return Err(profile_validation_error(
"profile_source_registered",
"Registered profile entry sources must be deleted through the profile registry API",
));
}
if full.exists() {
fs::remove_file(full)?;
}
read_profile_source_tree(workspace_id, workspace_root, source_tree_id)
}
pub fn build_workspace_profile_archive(
workspace_root: &Path,
selector: &str,
@@ -588,43 +763,8 @@ pub fn build_workspace_profile_archive(
return Ok(None);
}
let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
let mut entrypoints = BTreeMap::new();
let mut sources = BTreeMap::new();
for entry in project_entries(&registry, &mut Vec::new()) {
let path = archive_path_for_entry(&entry.name);
let full = checked_source_path(workspace_root, &entry.relative_path)?;
let content = fs::read_to_string(&full)?;
sources.insert(path.clone(), content);
entrypoints.insert(project_selector(&entry.name), path);
}
if !entrypoints.contains_key(selector) {
return Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "unknown_profile_selector".to_string(),
message: "Selected project profile is not present in the workspace profile registry"
.to_string(),
});
}
if let Some(default) = registry
.default
.as_deref()
.filter(|value| value.starts_with("project:"))
{
if let Some(path) = entrypoints.get(default).cloned() {
entrypoints.insert("default".to_string(), path);
}
}
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: "workspace-project-decodal-profiles-v1".to_string(),
entrypoints,
imports: BTreeMap::new(),
sources,
})
.map_err(|err| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_archive_invalid".to_string(),
message: err.to_string(),
})?;
let sources = read_profile_source_tree_contents(workspace_root)?;
let (archive, _) = build_profile_archive_from_tree_sources(&registry, sources)?;
archive
.verify()
.and_then(|verified| {
@@ -845,6 +985,305 @@ fn validate_registry_default(registry: &ProfileRegistryDocument) -> Result<()> {
))
}
fn profile_source_tree_summary(workspace_root: &Path) -> WorkspaceProfileSourceTreeSummary {
let file_count = list_profile_tree_files(workspace_root)
.map(|files| files.len())
.unwrap_or(0);
profile_source_tree_summary_with_count(workspace_root, file_count)
}
fn profile_source_tree_summary_with_count(
workspace_root: &Path,
file_count: usize,
) -> WorkspaceProfileSourceTreeSummary {
WorkspaceProfileSourceTreeSummary {
source_tree_id: PROFILE_SOURCE_TREE_ID.to_string(),
label: "Project profile sources".to_string(),
root_path: PROFILE_SOURCE_TREE_DISPLAY_ROOT.to_string(),
kind: "decodal_source_tree".to_string(),
editable: true,
revision: file_revision(&workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH)),
file_count,
diagnostics: Vec::new(),
}
}
fn ensure_profile_source_tree_id(source_tree_id: &str) -> Result<()> {
if source_tree_id == PROFILE_SOURCE_TREE_ID {
Ok(())
} else {
Err(Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "unknown_profile_source_tree".to_string(),
message: "Unknown profile source tree".to_string(),
})
}
}
fn list_profile_tree_files(
workspace_root: &Path,
) -> Result<Vec<WorkspaceProfileSourceTreeFileSummary>> {
let mut files = Vec::new();
collect_profile_tree_files(
workspace_root,
&workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH),
&mut files,
)?;
files.sort_by(|a, b| a.path.cmp(&b.path));
Ok(files)
}
fn collect_profile_tree_files(
workspace_root: &Path,
dir: &Path,
files: &mut Vec<WorkspaceProfileSourceTreeFileSummary>,
) -> Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
collect_profile_tree_files(workspace_root, &path, files)?;
} else if file_type.is_file()
&& path.extension().and_then(|value| value.to_str()) == Some("dcdl")
{
let relative = path
.strip_prefix(workspace_root)
.map_err(|_| {
profile_validation_error(
"profile_source_path_invalid",
"Profile source is outside workspace",
)
})?
.to_path_buf();
files.push(summarize_tree_file(&path, display_source_path(&relative)));
}
}
Ok(())
}
fn summarize_tree_file(path: &Path, virtual_path: String) -> WorkspaceProfileSourceTreeFileSummary {
WorkspaceProfileSourceTreeFileSummary {
path: virtual_path,
kind: "decodal".to_string(),
editable: true,
revision: file_revision(path),
size_bytes: source_metadata(path)
.map(|metadata| metadata.len())
.unwrap_or(0),
diagnostics: Vec::new(),
}
}
fn relative_source_path_for_virtual_path(virtual_path: &str) -> Result<PathBuf> {
let normalized = normalize_virtual_profile_source_path(virtual_path)?;
let Some(rest) = normalized.strip_prefix("profiles/") else {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source paths must be under profiles/",
));
};
if rest.is_empty() || !rest.ends_with(".dcdl") {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source files must use the .dcdl extension",
));
}
Ok(Path::new(PROFILE_SOURCE_ROOT_RELATIVE_PATH).join(rest))
}
fn display_source_path(relative_path: &Path) -> String {
let profile_root = Path::new(PROFILE_SOURCE_ROOT_RELATIVE_PATH);
let relative = relative_path
.strip_prefix(profile_root)
.unwrap_or(relative_path);
let rest = relative.to_string_lossy().replace('\\', "/");
format!("{PROFILE_SOURCE_TREE_DISPLAY_ROOT}/{rest}")
}
fn normalize_virtual_profile_source_path(path: &str) -> Result<String> {
let path = path
.strip_prefix("project:")
.or_else(|| path.strip_prefix("workspace:"))
.unwrap_or(path);
if path.is_empty() || path.contains("://") || Path::new(path).is_absolute() {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source path must be a virtual relative path",
));
}
let mut normalized = PathBuf::new();
for component in Path::new(path).components() {
match component {
Component::CurDir => {}
Component::Normal(value) => normalized.push(value),
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source path must not escape the virtual source tree",
));
}
}
}
let normalized = normalized.to_string_lossy().replace('\\', "/");
if normalized.is_empty() {
return Err(profile_validation_error(
"profile_source_path_invalid",
"Profile source path must not be empty",
));
}
Ok(normalized)
}
fn read_profile_source_tree_contents(workspace_root: &Path) -> Result<BTreeMap<String, String>> {
let mut sources = BTreeMap::new();
for file in list_profile_tree_files(workspace_root)? {
let relative = relative_source_path_for_virtual_path(&file.path)?;
let full = checked_source_path(workspace_root, &relative)?;
sources.insert(file.path, fs::read_to_string(full)?);
}
Ok(sources)
}
fn build_profile_archive_from_tree_sources(
registry: &ProfileRegistryDocument,
mut sources: BTreeMap<String, String>,
) -> Result<(ProfileSourceArchive, BTreeMap<String, String>)> {
let mut entrypoints = BTreeMap::new();
for entry in project_entries(registry, &mut Vec::new()) {
let path = display_source_path(&entry.relative_path);
if sources.contains_key(&path) {
entrypoints.insert(project_selector(&entry.name), path);
}
}
if let Some(default) = registry
.default
.as_deref()
.filter(|value| value.starts_with("project:"))
{
if let Some(path) = entrypoints.get(default).cloned() {
entrypoints.insert("default".to_string(), path);
}
}
let mut imports = BTreeMap::new();
let source_snapshot = sources.clone();
for (current_path, content) in &source_snapshot {
for specifier in collect_decodal_import_specifiers(content) {
let target = resolve_profile_source_import(current_path, &specifier)?;
if source_snapshot.contains_key(&target) {
imports.insert(format!("{current_path}\0{specifier}"), target);
} else {
return Err(profile_validation_error(
"profile_source_import_missing",
&format!(
"Profile source import {specifier:?} from {current_path} resolves to missing {target}"
),
));
}
}
}
if sources.is_empty() {
sources.insert("profiles/.empty.dcdl".to_string(), "{}".to_string());
}
ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: "workspace-project-decodal-profiles-v1".to_string(),
entrypoints,
imports: imports.clone(),
sources,
})
.map(|archive| (archive, imports))
.map_err(|err| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_archive_invalid".to_string(),
message: err.to_string(),
})
}
fn resolve_profile_source_import(current_path: &str, specifier: &str) -> Result<String> {
if specifier.is_empty() || specifier.contains("://") || Path::new(specifier).is_absolute() {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import must be a virtual relative path",
));
}
let raw = specifier
.strip_prefix("project:")
.or_else(|| specifier.strip_prefix("workspace:"))
.unwrap_or(specifier);
if specifier.contains(':') && raw == specifier {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Unsupported profile source import namespace",
));
}
let base = if raw.starts_with("profiles/") || raw.starts_with("./profiles/") {
PathBuf::from(raw)
} else {
Path::new(current_path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default()
.join(raw)
};
let normalized = normalize_virtual_profile_source_path(&base.to_string_lossy())?;
if !normalized.starts_with("profiles/") {
return Err(profile_validation_error(
"profile_source_import_invalid",
"Profile source import must resolve inside the profiles/ tree",
));
}
Ok(normalized)
}
fn collect_decodal_import_specifiers(content: &str) -> Vec<String> {
let mut specifiers = Vec::new();
for line in content.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with('#') {
continue;
}
let Some(index) = trimmed.find("import") else {
continue;
};
let after = trimmed[index + "import".len()..].trim_start();
let Some(first) = after.chars().next() else {
continue;
};
if first == '"' || first == '\'' {
if let Some(end) = after[1..].find(first) {
specifiers.push(after[1..1 + end].to_string());
}
} else {
let ident: String = after
.chars()
.take_while(|ch| !ch.is_whitespace() && *ch != ';' && *ch != ',' && *ch != '{')
.collect();
if !ident.is_empty() {
specifiers.push(ident);
}
}
}
specifiers
}
fn validate_tree_content(workspace_root: &Path, relative_path: &Path, content: &str) -> Result<()> {
let mut sources = read_profile_source_tree_contents(workspace_root)?;
sources.insert(display_source_path(relative_path), content.to_string());
let registry = read_registry(workspace_root).map_err(profile_registry_error)?;
let (archive, _) = build_profile_archive_from_tree_sources(&registry, sources)?;
archive
.verify()
.map_err(|err| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_invalid".to_string(),
message: err.to_string(),
})?;
Ok(())
}
fn validate_source_content(
workspace_root: &Path,
name: &str,
@@ -858,24 +1297,18 @@ fn validate_source_content(
message: "Profile source exceeds the browser editing size limit".to_string(),
});
}
let archive_path = archive_path_for_entry(name);
checked_source_path(workspace_root, relative_path)?;
let mut sources = read_profile_source_tree_contents(workspace_root)?;
sources.insert(display_source_path(relative_path), content.to_string());
let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?;
registry.profile.entry(name.to_string()).or_insert_with(|| {
ProfileEntryFile::Table(ProfileEntryTable {
path: display_source_path(relative_path),
description: None,
})
});
let selector = project_selector(name);
let mut entrypoints = BTreeMap::new();
entrypoints.insert(selector.clone(), archive_path.clone());
entrypoints.insert("default".to_string(), archive_path.clone());
let mut sources = BTreeMap::new();
sources.insert(archive_path, content.to_string());
let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: format!("workspace-profile-validation-{name}"),
entrypoints,
imports: BTreeMap::new(),
sources,
})
.map_err(|err| Error::RuntimeOperationFailed {
runtime_id: "workspace-backend".to_string(),
code: "profile_source_archive_invalid".to_string(),
message: err.to_string(),
})?;
let (archive, _) = build_profile_archive_from_tree_sources(&registry, sources)?;
let verified = archive
.verify()
.map_err(|err| Error::RuntimeOperationFailed {
@@ -883,7 +1316,6 @@ fn validate_source_content(
code: "profile_source_archive_invalid".to_string(),
message: err.to_string(),
})?;
checked_source_path(workspace_root, relative_path)?;
verified
.resolve_profile(&selector, workspace_root, "workspace-settings-validation")
.map_err(|err| Error::RuntimeOperationFailed {
@@ -1179,18 +1611,6 @@ pub fn selector_for_builtin_candidate(
}
}
fn archive_path_for_entry(name: &str) -> String {
format!("profiles/{name}.dcdl")
}
fn display_source_path(relative_path: &Path) -> String {
relative_path
.strip_prefix(".yoi")
.unwrap_or(relative_path)
.to_string_lossy()
.replace('\\', "/")
}
fn registry_path(workspace_root: &Path) -> PathBuf {
workspace_root.join(PROFILE_REGISTRY_RELATIVE_PATH)
}
+66 -1
View File
@@ -38,8 +38,9 @@ use crate::observation::{
};
use crate::profile_settings::{
CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest,
DeleteWorkspaceProfileTreeFileRequest, ReadWorkspaceProfileTreeFileQuery,
UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest,
UpdateWorkspaceProfileSourceRequest,
UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest,
};
use crate::records::{
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
@@ -281,6 +282,16 @@ pub fn build_router(api: WorkspaceApi) -> Router {
"/api/w/{workspace_id}/settings/profiles/registry",
put(scoped_update_profile_registry),
)
.route(
"/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}",
get(scoped_get_profile_source_tree),
)
.route(
"/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}/file",
get(scoped_get_profile_tree_file)
.put(scoped_write_profile_tree_file)
.delete(scoped_delete_profile_tree_file),
)
.route(
"/api/w/{workspace_id}/settings/profiles/{profile_source_id}",
get(scoped_get_profile_source)
@@ -907,6 +918,60 @@ async fn scoped_update_profile_registry(
)?))
}
async fn scoped_get_profile_source_tree(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::read_profile_source_tree(
&workspace_id,
&api.config.workspace_root,
&source_tree_id,
)?))
}
async fn scoped_get_profile_tree_file(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
Query(query): Query<ReadWorkspaceProfileTreeFileQuery>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::read_profile_tree_file(
&workspace_id,
&api.config.workspace_root,
&source_tree_id,
query,
)?))
}
async fn scoped_write_profile_tree_file(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
Json(request): Json<WriteWorkspaceProfileTreeFileRequest>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeFileResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::write_profile_tree_file(
&workspace_id,
&api.config.workspace_root,
&source_tree_id,
request,
)?))
}
async fn scoped_delete_profile_tree_file(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>,
Json(request): Json<DeleteWorkspaceProfileTreeFileRequest>,
) -> ApiResult<Json<crate::profile_settings::WorkspaceProfileSourceTreeResponse>> {
validate_workspace_scope(&api, &workspace_id)?;
Ok(Json(crate::profile_settings::delete_profile_tree_file(
&workspace_id,
&api.config.workspace_root,
&source_tree_id,
request,
)?))
}
async fn scoped_get_profile_source(
State(api): State<WorkspaceApi>,
AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>,