feat: add profile source tree editor
This commit is contained in:
@@ -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(®istry, &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(®istry, &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(®istry, 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(®istry, 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(®istry, 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)
|
||||
}
|
||||
|
||||
@@ -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)>,
|
||||
|
||||
Reference in New Issue
Block a user