use std::cmp::Ordering;
use std::fmt;
use std::path::{Component, Path, PathBuf};
use std::time::UNIX_EPOCH;
use soma_studio_core::{
AppConfig, WorkspaceEntryKind, WorkspaceFileChangeAction, WorkspaceFileChangeApplyResponse,
WorkspaceFileChangePreviewRequest, WorkspaceFileChangePreviewResponse, WorkspaceFileEntry,
WorkspaceFileListResponse, WorkspaceFilePreviewResponse,
};
const WORKSPACE_PREVIEW_MAX_BYTES: u64 = 256 * 1024;
const WORKSPACE_CHANGE_MAX_CONTENT_BYTES: usize = 512 * 1024;
const WORKSPACE_CHANGE_DIFF_PREVIEW_MAX_BYTES: usize = 64 * 1024;
pub type WorkspaceResult<T> = Result<T, WorkspaceError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceErrorKind {
InvalidRequest,
NotFound,
Conflict,
FileStale,
PreviewMissing,
Upstream,
Internal,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceError {
kind: WorkspaceErrorKind,
message: String,
}
impl WorkspaceError {
pub fn invalid_request(message: impl Into<String>) -> Self {
Self::new(WorkspaceErrorKind::InvalidRequest, message)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(WorkspaceErrorKind::NotFound, message)
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(WorkspaceErrorKind::Conflict, message)
}
pub fn file_stale(message: impl Into<String>) -> Self {
Self::new(WorkspaceErrorKind::FileStale, message)
}
pub fn preview_missing(message: impl Into<String>) -> Self {
Self::new(WorkspaceErrorKind::PreviewMissing, message)
}
pub fn upstream(message: impl Into<String>) -> Self {
Self::new(WorkspaceErrorKind::Upstream, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(WorkspaceErrorKind::Internal, message)
}
pub fn kind(&self) -> WorkspaceErrorKind {
self.kind
}
pub fn message(&self) -> &str {
&self.message
}
pub fn api_code(&self) -> &'static str {
match self.kind() {
WorkspaceErrorKind::InvalidRequest => "invalid_request",
WorkspaceErrorKind::NotFound => "not_found",
WorkspaceErrorKind::Conflict => "workspace_conflict",
WorkspaceErrorKind::FileStale => "workspace_file_stale",
WorkspaceErrorKind::PreviewMissing => "workspace_file_change_preview_missing",
WorkspaceErrorKind::Upstream => "upstream_error",
WorkspaceErrorKind::Internal => "internal_error",
}
}
pub fn status_code(&self) -> u16 {
match self.kind() {
WorkspaceErrorKind::InvalidRequest => 400,
WorkspaceErrorKind::NotFound | WorkspaceErrorKind::PreviewMissing => 404,
WorkspaceErrorKind::Conflict | WorkspaceErrorKind::FileStale => 409,
WorkspaceErrorKind::Upstream => 502,
WorkspaceErrorKind::Internal => 500,
}
}
fn new(kind: WorkspaceErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
}
impl fmt::Display for WorkspaceError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for WorkspaceError {}
pub fn list_workspace_files(
config: &AppConfig,
raw_path: Option<&str>,
) -> WorkspaceResult<WorkspaceFileListResponse> {
let canonical_root = config.project_root.canonicalize().map_err(|error| {
WorkspaceError::internal(format!("failed to canonicalize workspace root: {error}"))
})?;
let segments = parse_workspace_path(raw_path.unwrap_or_default())?;
let relative_path = segments.join("/");
let target = resolve_workspace_path(&canonical_root, &segments)?;
if !target.is_dir() {
return Err(WorkspaceError::invalid_request(format!(
"workspace path must be a directory: {}",
relative_path_label(&relative_path)
)));
}
let mut entries = Vec::new();
for entry in std::fs::read_dir(&target).map_err(|error| {
WorkspaceError::internal(format!("failed to read workspace directory: {error}"))
})? {
let entry = entry.map_err(|error| {
WorkspaceError::internal(format!("failed to read workspace entry: {error}"))
})?;
let file_type = entry.file_type().map_err(|error| {
WorkspaceError::internal(format!("failed to inspect workspace entry type: {error}"))
})?;
if file_type.is_symlink() {
continue;
}
if !file_type.is_dir() && !file_type.is_file() {
continue;
}
let name = entry
.file_name()
.to_str()
.ok_or_else(|| {
WorkspaceError::invalid_request("workspace entry name must be valid UTF-8")
})?
.to_string();
let kind = if file_type.is_dir() {
WorkspaceEntryKind::Directory
} else {
WorkspaceEntryKind::File
};
let metadata = entry.metadata().map_err(|error| {
WorkspaceError::internal(format!(
"failed to inspect workspace entry metadata: {error}"
))
})?;
entries.push(WorkspaceFileEntry {
path: join_relative_path(&relative_path, &name),
name,
kind,
size_bytes: (kind == WorkspaceEntryKind::File).then_some(metadata.len()),
modified_at_ms: modified_at_ms(&metadata),
});
}
entries.sort_by(compare_workspace_entries);
Ok(WorkspaceFileListResponse {
root: canonical_root.to_string_lossy().to_string(),
path: relative_path.clone(),
parent_path: parent_workspace_path(&segments),
entries,
})
}
pub fn preview_workspace_file(
config: &AppConfig,
raw_path: Option<&str>,
) -> WorkspaceResult<WorkspaceFilePreviewResponse> {
let canonical_root = config.project_root.canonicalize().map_err(|error| {
WorkspaceError::internal(format!("failed to canonicalize workspace root: {error}"))
})?;
let segments = parse_workspace_path(raw_path.unwrap_or_default())?;
if segments.is_empty() {
return Err(WorkspaceError::invalid_request(
"workspace file path is required",
));
}
let relative_path = segments.join("/");
let target = resolve_workspace_path(&canonical_root, &segments)?;
if !target.is_file() {
return Err(WorkspaceError::invalid_request(format!(
"workspace preview path must be a file: {}",
relative_path_label(&relative_path)
)));
}
if !is_previewable_text_path(&target) {
return Err(WorkspaceError::invalid_request(format!(
"unsupported workspace preview file type: {}",
relative_path_label(&relative_path)
)));
}
let metadata = std::fs::metadata(&target).map_err(|error| {
WorkspaceError::internal(format!("failed to inspect workspace preview file: {error}"))
})?;
if metadata.len() > WORKSPACE_PREVIEW_MAX_BYTES {
return Err(WorkspaceError::invalid_request(format!(
"workspace preview file must be {WORKSPACE_PREVIEW_MAX_BYTES} bytes or smaller"
)));
}
let content = std::fs::read_to_string(&target).map_err(|error| {
WorkspaceError::invalid_request(format!("invalid UTF-8 workspace preview file: {error}"))
})?;
Ok(WorkspaceFilePreviewResponse {
root: canonical_root.to_string_lossy().to_string(),
path: relative_path,
size_bytes: metadata.len(),
modified_at_ms: modified_at_ms(&metadata),
content,
truncated: false,
max_bytes: WORKSPACE_PREVIEW_MAX_BYTES,
})
}
pub fn preview_workspace_file_change(
config: &AppConfig,
input: &WorkspaceFileChangePreviewRequest,
preview_token: uuid::Uuid,
expires_at_ms: u64,
) -> WorkspaceResult<(WorkspaceFileChangePreviewResponse, Option<u64>, Option<u64>)> {
let (target, relative_path) = resolve_workspace_change_target(config, &input.path)?;
let before = read_workspace_change_before(&target, input.action)?;
if let Some(expected) = input.expected_modified_at_ms
&& before.modified_at_ms != Some(expected)
{
return Err(WorkspaceError::file_stale(
"workspace file revision must match before preview",
));
}
let rename_target = workspace_change_rename_target(config, input, &relative_path)?;
let next_content = workspace_change_next_content(input, before.content.as_deref())?;
let (diff_preview, diff_truncated) = match &rename_target {
Some((_, target_relative_path)) => {
rename_diff_preview(&relative_path, target_relative_path)
}
None => diff_preview(
before.content.as_deref(),
next_content.as_deref(),
&relative_path,
),
};
let size_bytes_after = match input.action {
WorkspaceFileChangeAction::DeleteFile => None,
WorkspaceFileChangeAction::RenamePath => before.size_bytes,
WorkspaceFileChangeAction::WriteText => {
next_content.as_ref().map(|content| content.len() as u64)
}
};
let response = WorkspaceFileChangePreviewResponse {
preview_token,
action: input.action,
path: relative_path,
target_path: rename_target.map(|(_, target_relative_path)| target_relative_path),
exists_before: before.exists,
size_bytes_before: before.size_bytes,
modified_at_ms_before: before.modified_at_ms,
size_bytes_after,
diff_preview,
diff_truncated,
expires_at_ms,
max_content_bytes: WORKSPACE_CHANGE_MAX_CONTENT_BYTES,
};
Ok((response, before.modified_at_ms, before.size_bytes))
}
pub fn apply_workspace_file_change(
config: &AppConfig,
input: &WorkspaceFileChangePreviewRequest,
preview_modified_at_ms: Option<u64>,
preview_size_bytes: Option<u64>,
) -> WorkspaceResult<WorkspaceFileChangeApplyResponse> {
let (target, relative_path) = resolve_workspace_change_target(config, &input.path)?;
let before = read_workspace_change_before(&target, input.action)?;
if before.modified_at_ms != preview_modified_at_ms || before.size_bytes != preview_size_bytes {
return Err(WorkspaceError::file_stale(
"workspace file is stale; create a new preview",
));
}
let rename_target = workspace_change_rename_target(config, input, &relative_path)?;
let mut after_target = target.clone();
match input.action {
WorkspaceFileChangeAction::WriteText => {
let content = workspace_change_required_content(input)?;
std::fs::write(&target, content).map_err(|error| {
WorkspaceError::internal(format!("failed to write workspace file: {error}"))
})?;
}
WorkspaceFileChangeAction::DeleteFile => {
if !before.exists {
return Err(WorkspaceError::invalid_request(
"workspace file delete target must exist",
));
}
std::fs::remove_file(&target).map_err(|error| {
WorkspaceError::internal(format!("failed to delete workspace file: {error}"))
})?;
}
WorkspaceFileChangeAction::RenamePath => {
let Some((destination, _)) = &rename_target else {
return Err(WorkspaceError::invalid_request(
"workspace rename target path is required",
));
};
rename_workspace_file_without_overwrite(&target, destination)?;
after_target = destination.clone();
}
}
let after_metadata = std::fs::metadata(&after_target).ok();
Ok(WorkspaceFileChangeApplyResponse {
applied: true,
action: input.action,
path: relative_path,
target_path: rename_target.map(|(_, target_relative_path)| target_relative_path),
exists_after: after_metadata.is_some(),
size_bytes_after: after_metadata.as_ref().map(std::fs::Metadata::len),
modified_at_ms_after: after_metadata.as_ref().and_then(modified_at_ms),
})
}
pub fn resolve_workspace_directory(config: &AppConfig, raw_path: &str) -> WorkspaceResult<PathBuf> {
let (target, relative_path) = resolve_workspace_existing_path(config, raw_path)?;
if !target.is_dir() {
return Err(WorkspaceError::invalid_request(format!(
"workspace path must be a directory: {}",
relative_path_label(&relative_path)
)));
}
Ok(target)
}
struct WorkspaceChangeBefore {
exists: bool,
size_bytes: Option<u64>,
modified_at_ms: Option<u64>,
content: Option<String>,
}
fn resolve_workspace_change_target(
config: &AppConfig,
raw_path: &str,
) -> WorkspaceResult<(PathBuf, String)> {
let canonical_root = config.project_root.canonicalize().map_err(|error| {
WorkspaceError::internal(format!("failed to canonicalize workspace root: {error}"))
})?;
let segments = parse_workspace_path(raw_path)?;
if segments.is_empty() {
return Err(WorkspaceError::invalid_request(
"workspace file path is required",
));
}
let relative_path = segments.join("/");
let parent_segments = &segments[..segments.len() - 1];
let parent = resolve_workspace_path(&canonical_root, parent_segments)?;
if !parent.is_dir() {
return Err(WorkspaceError::invalid_request(
"workspace file parent must be a directory",
));
}
let target = parent.join(
segments
.last()
.ok_or_else(|| WorkspaceError::invalid_request("workspace file path is required"))?,
);
if let Ok(metadata) = std::fs::symlink_metadata(&target) {
if metadata.file_type().is_symlink() {
return Err(WorkspaceError::invalid_request(
"workspace file target is blocked because it is a symlink",
));
}
if metadata.is_dir() {
return Err(WorkspaceError::invalid_request(
"workspace file change target must be a file",
));
}
}
if target.try_exists().map_err(|error| {
WorkspaceError::internal(format!("failed to inspect workspace file target: {error}"))
})? {
let canonical_target = target.canonicalize().map_err(|error| {
WorkspaceError::internal(format!(
"failed to canonicalize workspace file target: {error}"
))
})?;
if !canonical_target.starts_with(&canonical_root) {
return Err(WorkspaceError::invalid_request(
"workspace file target escapes the project root",
));
}
}
if !is_previewable_text_path(&target) {
return Err(WorkspaceError::invalid_request(format!(
"unsupported workspace file change type: {}",
relative_path_label(&relative_path)
)));
}
Ok((target, relative_path))
}
fn read_workspace_change_before(
target: &Path,
action: WorkspaceFileChangeAction,
) -> WorkspaceResult<WorkspaceChangeBefore> {
if !target.try_exists().map_err(|error| {
WorkspaceError::internal(format!("failed to inspect workspace file: {error}"))
})? {
if action != WorkspaceFileChangeAction::WriteText {
return Err(WorkspaceError::invalid_request(
"workspace file change target must exist",
));
}
return Ok(WorkspaceChangeBefore {
exists: false,
size_bytes: None,
modified_at_ms: None,
content: None,
});
}
let metadata = std::fs::metadata(target).map_err(|error| {
WorkspaceError::internal(format!("failed to inspect workspace file: {error}"))
})?;
if metadata.len() > WORKSPACE_PREVIEW_MAX_BYTES {
return Err(WorkspaceError::invalid_request(format!(
"workspace file change target must be {WORKSPACE_PREVIEW_MAX_BYTES} bytes or smaller"
)));
}
let content = std::fs::read_to_string(target).map_err(|error| {
WorkspaceError::invalid_request(format!("invalid UTF-8 workspace file: {error}"))
})?;
Ok(WorkspaceChangeBefore {
exists: true,
size_bytes: Some(metadata.len()),
modified_at_ms: modified_at_ms(&metadata),
content: Some(content),
})
}
fn workspace_change_next_content(
input: &WorkspaceFileChangePreviewRequest,
current_content: Option<&str>,
) -> WorkspaceResult<Option<String>> {
match input.action {
WorkspaceFileChangeAction::WriteText => Ok(Some(workspace_change_required_content(input)?)),
WorkspaceFileChangeAction::DeleteFile => {
if input.content.is_some() {
return Err(WorkspaceError::invalid_request(
"workspace delete preview must not include content",
));
}
if current_content.is_none() {
return Err(WorkspaceError::invalid_request(
"workspace file delete target must exist",
));
}
Ok(None)
}
WorkspaceFileChangeAction::RenamePath => {
if input.content.is_some() {
return Err(WorkspaceError::invalid_request(
"workspace rename preview must not include content",
));
}
if current_content.is_none() {
return Err(WorkspaceError::invalid_request(
"workspace file rename target must exist",
));
}
Ok(current_content.map(ToString::to_string))
}
}
}
fn workspace_change_rename_target(
config: &AppConfig,
input: &WorkspaceFileChangePreviewRequest,
source_relative_path: &str,
) -> WorkspaceResult<Option<(PathBuf, String)>> {
if input.action != WorkspaceFileChangeAction::RenamePath {
if input.target_path.is_some() {
return Err(WorkspaceError::invalid_request(
"workspace target path is only valid for rename",
));
}
return Ok(None);
}
let target_path = input.target_path.as_deref().ok_or_else(|| {
WorkspaceError::invalid_request("workspace rename target path is required")
})?;
let (target, target_relative_path) = resolve_workspace_change_target(config, target_path)?;
if target_relative_path == source_relative_path {
return Err(WorkspaceError::invalid_request(
"workspace rename target path must differ from source path",
));
}
if target.try_exists().map_err(|error| {
WorkspaceError::internal(format!(
"failed to inspect workspace rename target: {error}"
))
})? {
return Err(WorkspaceError::conflict(
"workspace rename target already exists",
));
}
Ok(Some((target, target_relative_path)))
}
fn workspace_change_required_content(
input: &WorkspaceFileChangePreviewRequest,
) -> WorkspaceResult<String> {
let content = input.content.as_ref().ok_or_else(|| {
WorkspaceError::invalid_request("workspace write preview content is required")
})?;
if content.len() > WORKSPACE_CHANGE_MAX_CONTENT_BYTES {
return Err(WorkspaceError::invalid_request(format!(
"workspace write preview content must be {WORKSPACE_CHANGE_MAX_CONTENT_BYTES} bytes or smaller"
)));
}
Ok(content.clone())
}
fn diff_preview(before: Option<&str>, after: Option<&str>, path: &str) -> (String, bool) {
let mut output = String::new();
output.push_str(&format!("--- {path}\n+++ {path}\n"));
match (before, after) {
(None, Some(after)) => push_prefixed_lines(&mut output, '+', after),
(Some(before), None) => push_prefixed_lines(&mut output, '-', before),
(Some(before), Some(after)) if before == after => output.push_str(" no content changes\n"),
(Some(before), Some(after)) => {
push_prefixed_lines(&mut output, '-', before);
push_prefixed_lines(&mut output, '+', after);
}
(None, None) => output.push_str(" no content changes\n"),
}
truncate_string(output, WORKSPACE_CHANGE_DIFF_PREVIEW_MAX_BYTES)
}
fn rename_diff_preview(source_path: &str, target_path: &str) -> (String, bool) {
truncate_string(
format!("rename from {source_path}\nrename to {target_path}\n"),
WORKSPACE_CHANGE_DIFF_PREVIEW_MAX_BYTES,
)
}
fn rename_workspace_file_without_overwrite(
source: &Path,
destination: &Path,
) -> WorkspaceResult<()> {
let bytes = std::fs::read(source).map_err(|error| {
WorkspaceError::internal(format!("failed to read workspace rename source: {error}"))
})?;
let mut destination_file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(destination)
.map_err(|error| {
if destination.try_exists().unwrap_or(false) {
WorkspaceError::conflict("workspace rename target already exists")
} else {
WorkspaceError::internal(format!(
"failed to create workspace rename target without overwrite: {error}"
))
}
})?;
if let Err(error) = std::io::Write::write_all(&mut destination_file, &bytes) {
let _ = std::fs::remove_file(destination);
return Err(WorkspaceError::internal(format!(
"failed to write workspace rename target: {error}"
)));
}
if let Err(error) = destination_file.sync_all() {
let _ = std::fs::remove_file(destination);
return Err(WorkspaceError::internal(format!(
"failed to flush workspace rename target: {error}"
)));
}
drop(destination_file);
if let Err(error) = std::fs::remove_file(source) {
let _ = std::fs::remove_file(destination);
return Err(WorkspaceError::internal(format!(
"failed to remove workspace rename source: {error}"
)));
}
Ok(())
}
fn push_prefixed_lines(output: &mut String, prefix: char, content: &str) {
if content.is_empty() {
output.push(prefix);
output.push('\n');
return;
}
for line in content.lines() {
output.push(prefix);
output.push_str(line);
output.push('\n');
}
}
fn truncate_string(mut value: String, max_bytes: usize) -> (String, bool) {
if value.len() <= max_bytes {
return (value, false);
}
let mut boundary = max_bytes;
while !value.is_char_boundary(boundary) {
boundary -= 1;
}
value.truncate(boundary);
value.push_str("\n... truncated ...\n");
(value, true)
}
pub fn resolve_workspace_existing_path(
config: &AppConfig,
raw_path: &str,
) -> WorkspaceResult<(PathBuf, String)> {
let canonical_root = config.project_root.canonicalize().map_err(|error| {
WorkspaceError::internal(format!("failed to canonicalize workspace root: {error}"))
})?;
let segments = parse_workspace_path(raw_path)?;
let relative_path = segments.join("/");
let target = resolve_workspace_path(&canonical_root, &segments)?;
Ok((target, relative_path))
}
fn parse_workspace_path(raw_path: &str) -> WorkspaceResult<Vec<String>> {
let normalized = raw_path.trim().replace('\\', "/");
if normalized.is_empty() || normalized == "." {
return Ok(Vec::new());
}
let mut segments = Vec::new();
for component in Path::new(&normalized).components() {
match component {
Component::Normal(segment) => {
let segment = segment.to_str().ok_or_else(|| {
WorkspaceError::invalid_request("workspace path segment must be valid UTF-8")
})?;
if segment.is_empty() {
return Err(WorkspaceError::invalid_request(
"workspace path contains an empty segment",
));
}
if segment.contains(':') {
return Err(WorkspaceError::invalid_request(
"workspace path must not contain drive or stream separators",
));
}
segments.push(segment.to_string());
}
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(WorkspaceError::invalid_request(
"workspace path must stay inside the project root",
));
}
}
}
Ok(segments)
}
fn resolve_workspace_path(root: &Path, segments: &[String]) -> WorkspaceResult<PathBuf> {
let mut target = root.to_path_buf();
for segment in segments {
target.push(segment);
if let Ok(metadata) = std::fs::symlink_metadata(&target)
&& metadata.file_type().is_symlink()
{
return Err(WorkspaceError::invalid_request(
"workspace path segment is blocked because it is a symlink",
));
}
}
if !target.try_exists().map_err(|error| {
WorkspaceError::internal(format!("failed to inspect workspace path: {error}"))
})? {
let relative_path = segments.join("/");
return Err(WorkspaceError::not_found(format!(
"workspace path not found: {}",
relative_path_label(&relative_path)
)));
}
let canonical_target = target.canonicalize().map_err(|error| {
WorkspaceError::internal(format!("failed to canonicalize workspace path: {error}"))
})?;
if !canonical_target.starts_with(root) {
return Err(WorkspaceError::invalid_request(
"workspace path escapes the project root",
));
}
Ok(canonical_target)
}
fn compare_workspace_entries(left: &WorkspaceFileEntry, right: &WorkspaceFileEntry) -> Ordering {
match (left.kind, right.kind) {
(WorkspaceEntryKind::Directory, WorkspaceEntryKind::File) => Ordering::Less,
(WorkspaceEntryKind::File, WorkspaceEntryKind::Directory) => Ordering::Greater,
_ => left.name.to_lowercase().cmp(&right.name.to_lowercase()),
}
}
fn join_relative_path(parent: &str, name: &str) -> String {
if parent.is_empty() {
name.to_string()
} else {
format!("{parent}/{name}")
}
}
fn parent_workspace_path(segments: &[String]) -> Option<String> {
if segments.is_empty() {
return None;
}
Some(segments[..segments.len() - 1].join("/"))
}
fn relative_path_label(path: &str) -> &str {
if path.is_empty() { "." } else { path }
}
fn is_previewable_text_path(path: &Path) -> bool {
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
let file_name = file_name.to_lowercase();
if matches!(
file_name.as_str(),
".gitignore" | ".gitattributes" | "license" | "notice"
) {
return true;
}
let Some(extension) = path
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_lowercase())
else {
return false;
};
matches!(
extension.as_str(),
"bat"
| "c"
| "cmd"
| "conf"
| "cpp"
| "cs"
| "css"
| "cts"
| "csv"
| "go"
| "gradle"
| "h"
| "hpp"
| "html"
| "ini"
| "java"
| "js"
| "jsx"
| "json"
| "kt"
| "lock"
| "log"
| "mjs"
| "md"
| "mts"
| "properties"
| "ps1"
| "py"
| "rs"
| "sh"
| "sql"
| "svelte"
| "toml"
| "ts"
| "tsx"
| "txt"
| "typ"
| "vue"
| "xml"
| "yaml"
| "yml"
)
}
fn modified_at_ms(metadata: &std::fs::Metadata) -> Option<u64> {
metadata
.modified()
.ok()
.and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as u64)
}
#[cfg(test)]
mod tests {
use soma_studio_core::{
AppConfig, WorkspaceEntryKind, WorkspaceFileChangeAction, WorkspaceFileChangePreviewRequest,
};
use uuid::Uuid;
use super::{
WORKSPACE_PREVIEW_MAX_BYTES, WorkspaceErrorKind, list_workspace_files,
preview_workspace_file, preview_workspace_file_change,
};
#[test]
fn lists_directories_before_files_inside_project_root() {
let temp_dir =
std::env::temp_dir().join(format!("soma-workspace-files-{}", Uuid::new_v4()));
std::fs::create_dir_all(temp_dir.join("docs")).expect("docs dir");
std::fs::write(temp_dir.join("README.md"), "# Readme").expect("readme");
let config = test_config(temp_dir.clone());
let response = list_workspace_files(&config, None).expect("workspace files");
assert_eq!(response.path, "");
assert_eq!(response.parent_path, None);
assert_eq!(response.entries[0].name, "docs");
assert_eq!(response.entries[0].kind, WorkspaceEntryKind::Directory);
assert_eq!(response.entries[1].name, "README.md");
assert_eq!(response.entries[1].kind, WorkspaceEntryKind::File);
let _ = std::fs::remove_dir_all(temp_dir);
}
#[test]
fn rejects_paths_outside_project_root() {
let temp_dir =
std::env::temp_dir().join(format!("soma-workspace-files-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = test_config(temp_dir.clone());
assert!(list_workspace_files(&config, Some("..")).is_err());
assert!(list_workspace_files(&config, Some("/tmp")).is_err());
assert!(list_workspace_files(&config, Some("C:/tmp")).is_err());
assert!(list_workspace_files(&config, Some("missing")).is_err());
let _ = std::fs::remove_dir_all(temp_dir);
}
#[test]
fn previews_small_utf8_text_files_only() {
let temp_dir =
std::env::temp_dir().join(format!("soma-workspace-preview-{}", Uuid::new_v4()));
std::fs::create_dir_all(temp_dir.join("docs")).expect("docs dir");
std::fs::write(temp_dir.join("docs").join("guide.md"), "# Guide").expect("guide");
std::fs::write(temp_dir.join("image.bin"), [0, 159, 146, 150]).expect("binary");
let oversized = temp_dir.join("huge.txt");
std::fs::write(
&oversized,
"a".repeat((WORKSPACE_PREVIEW_MAX_BYTES + 1) as usize),
)
.expect("oversized");
let config = test_config(temp_dir.clone());
let preview = preview_workspace_file(&config, Some("docs/guide.md")).expect("preview");
assert_eq!(preview.path, "docs/guide.md");
assert_eq!(preview.content, "# Guide");
assert!(!preview.truncated);
assert!(preview_workspace_file(&config, Some("docs")).is_err());
assert!(preview_workspace_file(&config, Some("image.bin")).is_err());
assert!(preview_workspace_file(&config, Some("huge.txt")).is_err());
let _ = std::fs::remove_dir_all(temp_dir);
}
#[test]
fn rename_conflict_returns_typed_workspace_error() {
let temp_dir =
std::env::temp_dir().join(format!("soma-workspace-change-{}", Uuid::new_v4()));
std::fs::create_dir_all(temp_dir.join("docs")).expect("docs dir");
std::fs::write(temp_dir.join("docs").join("source.md"), "# Source").expect("source");
std::fs::write(temp_dir.join("docs").join("existing.md"), "# Existing").expect("existing");
let config = test_config(temp_dir.clone());
let input = WorkspaceFileChangePreviewRequest {
action: WorkspaceFileChangeAction::RenamePath,
path: "docs/source.md".to_string(),
target_path: Some("docs/existing.md".to_string()),
content: None,
expected_modified_at_ms: None,
};
let error = preview_workspace_file_change(&config, &input, Uuid::new_v4(), 1)
.expect_err("rename conflict");
assert_eq!(error.kind(), WorkspaceErrorKind::Conflict);
assert_eq!(error.api_code(), "workspace_conflict");
assert_eq!(error.status_code(), 409);
let _ = std::fs::remove_dir_all(temp_dir);
}
#[test]
fn stale_preview_returns_typed_workspace_file_stale_error() {
let temp_dir =
std::env::temp_dir().join(format!("soma-workspace-stale-{}", Uuid::new_v4()));
std::fs::create_dir_all(temp_dir.join("docs")).expect("docs dir");
std::fs::write(temp_dir.join("docs").join("guide.md"), "# Guide").expect("guide");
let config = test_config(temp_dir.clone());
let input = WorkspaceFileChangePreviewRequest {
action: WorkspaceFileChangeAction::WriteText,
path: "docs/guide.md".to_string(),
target_path: None,
content: Some("# Updated".to_string()),
expected_modified_at_ms: Some(u64::MAX),
};
let error = preview_workspace_file_change(&config, &input, Uuid::new_v4(), 1)
.expect_err("stale preview");
assert_eq!(error.kind(), WorkspaceErrorKind::FileStale);
assert_eq!(error.api_code(), "workspace_file_stale");
assert_eq!(error.status_code(), 409);
let _ = std::fs::remove_dir_all(temp_dir);
}
fn test_config(project_root: std::path::PathBuf) -> AppConfig {
AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
data_dir: project_root.join(".soma-studio-data"),
derived_dir: project_root.join(".soma-studio-data").join("derived"),
notebook_dir: project_root.join(".soma-studio-data").join("notebook"),
user_assets_dir: project_root.join(".soma-studio-data").join("assets"),
db_path: project_root.join(".soma-studio-data").join("test.db"),
web_build_dir: project_root.join("web").join("build"),
web_shell_file: project_root.join("web").join("build").join("spa.html"),
project_root,
}
}
}