use std::collections::HashSet;
use std::ffi::OsStr;
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
use soma_studio_core::{
AppConfig, NotebookAdapterStatus, NotebookChunkItem, NotebookChunkResponse,
NotebookEmbeddingItem, NotebookEmbeddingResponse, NotebookIndexItem, NotebookIndexResponse,
NotebookNoteContent, NotebookNoteCreateRequest, NotebookNoteFormat, NotebookNoteSummary,
NotebookNoteWriteRequest, NotebookRenderRequest, NotebookRenderResponse,
NotebookRetrievalResponse, NotebookRetrievalResult, NotebookSearchResult, SearchProfile,
SearchQuery, lexical_score, search_snippet,
};
const TYPST_COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
pub fn adapter_statuses() -> Vec<NotebookAdapterStatus> {
vec![typst_adapter_status()]
}
pub fn list_notes(config: &AppConfig) -> Result<Vec<NotebookNoteSummary>, String> {
ensure_notebook_root(config)?;
let mut notes = Vec::new();
collect_notes(
config,
&config.notebook_dir,
&config.notebook_dir,
&mut notes,
)?;
notes.sort_by(|left, right| left.path.cmp(&right.path));
Ok(notes)
}
pub fn read_note(config: &AppConfig, raw_path: &str) -> Result<NotebookNoteContent, String> {
let relative = validate_note_path(raw_path)?;
let resolved = existing_note_path(config, &relative)?;
let content = std::fs::read_to_string(&resolved)
.map_err(|error| format!("failed to read note {}: {error}", display_path(&relative)))?;
let format = format_from_path(&relative)?;
Ok(content_response(config, relative, format, content))
}
pub fn read_artifact(config: &AppConfig, raw_path: &str) -> Result<Vec<u8>, String> {
let relative = validate_artifact_path(raw_path)?;
let mut source_relative = relative.clone();
source_relative.set_extension("typ");
if !artifact_is_current(config, &source_relative, "pdf") {
return Err("notebook artifact is missing or stale".to_string());
}
let artifact = existing_artifact_path(config, &relative)?;
std::fs::read(&artifact).map_err(|error| {
format!(
"failed to read notebook artifact {}: {error}",
display_path(&relative)
)
})
}
pub fn create_note(
config: &AppConfig,
input: NotebookNoteCreateRequest,
) -> Result<NotebookNoteContent, String> {
ensure_notebook_root(config)?;
let relative = validate_note_path(&input.path)?;
let format = format_from_path(&relative)?;
if format != input.format {
return Err(format!(
"note path extension does not match requested format: {}",
display_path(&relative)
));
}
let target = creatable_note_path(config, &relative)?;
if target.exists() {
return Err(format!("note already exists: {}", display_path(&relative)));
}
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("failed to create note directory: {error}"))?;
ensure_within_root(parent, &config.notebook_dir)?;
}
let content = starter_template(&relative, format, input.template_id.as_deref());
std::fs::write(&target, &content)
.map_err(|error| format!("failed to create note {}: {error}", display_path(&relative)))?;
Ok(content_response(config, relative, format, content))
}
pub fn write_note(
config: &AppConfig,
input: NotebookNoteWriteRequest,
) -> Result<NotebookNoteContent, String> {
let relative = validate_note_path(&input.path)?;
let target = existing_note_path(config, &relative)?;
let format = format_from_path(&relative)?;
std::fs::write(&target, &input.content)
.map_err(|error| format!("failed to write note {}: {error}", display_path(&relative)))?;
if format == NotebookNoteFormat::Typst {
let artifact = artifact_file_path(config, &relative, "pdf");
if artifact.exists() {
std::fs::remove_file(&artifact)
.map_err(|error| format!("failed to remove stale Typst artifact: {error}"))?;
}
}
remove_stale_index(config, &relative)?;
Ok(content_response(config, relative, format, input.content))
}
pub fn search_notes(config: &AppConfig, query: &str) -> Result<Vec<NotebookSearchResult>, String> {
let query = query.trim();
if query.is_empty() {
return Ok(Vec::new());
}
let query_lower = query.to_lowercase();
let mut results = Vec::new();
for note in list_notes(config)? {
let content = read_note(config, ¬e.path)?.content;
let indexable =
load_or_normalize_indexable_text(config, ¬e.path, note.format, &content)?;
let content_lower = indexable.to_lowercase();
let path_lower = note.path.to_lowercase();
if !content_lower.contains(&query_lower) && !path_lower.contains(&query_lower) {
continue;
}
results.push(NotebookSearchResult {
path: note.path,
title: note.title,
format: note.format,
snippet: build_snippet(&indexable, query),
render_status: note.render_status,
artifact_available: note.artifact_url.is_some(),
});
}
Ok(results)
}
pub fn index_notes(config: &AppConfig) -> Result<NotebookIndexResponse, String> {
let mut items = Vec::new();
for note in list_notes(config)? {
let content = read_note(config, ¬e.path)?.content;
let indexable = normalize_indexable_text(note.format, &content);
let index_path = write_indexable_text(config, ¬e.path, &indexable)?;
items.push(NotebookIndexItem {
path: note.path.clone(),
format: note.format,
index_path,
provenance: index_provenance(¬e.path, note.format),
bytes: indexable.len(),
});
}
Ok(NotebookIndexResponse {
indexed: items.len(),
items,
})
}
pub fn chunk_notes(config: &AppConfig) -> Result<NotebookChunkResponse, String> {
let mut items = Vec::new();
for note in list_notes(config)? {
let content = read_note(config, ¬e.path)?.content;
let indexable =
load_or_normalize_indexable_text(config, ¬e.path, note.format, &content)?;
let chunks = chunk_text(&indexable);
let chunk_path = write_chunks(config, ¬e.path, &chunks)?;
items.push(NotebookChunkItem {
path: note.path.clone(),
format: note.format,
chunk_path,
chunks: chunks.len(),
provenance: index_provenance(¬e.path, note.format),
});
}
Ok(NotebookChunkResponse {
chunked: items.len(),
items,
})
}
pub fn chunk_status(config: &AppConfig) -> Result<NotebookChunkResponse, String> {
let mut items = Vec::new();
for note in list_notes(config)? {
let artifact = chunk_artifact_path(config, Path::new(¬e.path));
if !chunk_is_current(config, Path::new(¬e.path)) {
continue;
}
let chunks = std::fs::read_to_string(&artifact)
.ok()
.and_then(|content| serde_json::from_str::<Vec<serde_json::Value>>(&content).ok())
.map(|items| items.len())
.unwrap_or_default();
items.push(NotebookChunkItem {
path: note.path.clone(),
format: note.format,
chunk_path: artifact
.strip_prefix(&config.derived_dir)
.map(display_path)
.unwrap_or_else(|_| artifact.to_string_lossy().to_string()),
chunks,
provenance: index_provenance(¬e.path, note.format),
});
}
Ok(NotebookChunkResponse {
chunked: items.len(),
items,
})
}
pub fn retrieve_notes(
config: &AppConfig,
query: &str,
) -> Result<NotebookRetrievalResponse, String> {
retrieve_notes_with_profile(config, query, SearchProfile::RagContext)
}
pub fn retrieve_notes_with_profile(
config: &AppConfig,
query: &str,
profile: SearchProfile,
) -> Result<NotebookRetrievalResponse, String> {
let query = SearchQuery::new(query, profile);
if query.is_empty() {
return Ok(NotebookRetrievalResponse {
query: String::new(),
strategy: "none".to_string(),
results: Vec::new(),
});
}
let mut results = Vec::new();
for note in list_notes(config)? {
let relative = Path::new(¬e.path);
let chunk_path = chunk_artifact_path(config, relative)
.strip_prefix(&config.derived_dir)
.map(display_path)
.unwrap_or_else(|_| {
chunk_artifact_path(config, relative)
.to_string_lossy()
.to_string()
});
let chunks = load_or_build_chunks(config, ¬e.path, note.format)?;
for (chunk_index, chunk) in chunks.into_iter().enumerate() {
let score = lexical_score(&chunk, &query);
if score == 0 {
continue;
}
results.push(NotebookRetrievalResult {
path: note.path.clone(),
format: note_format_string(note.format),
chunk_path: chunk_path.clone(),
chunk_index,
score,
snippet: search_snippet(&chunk, 240),
provenance: index_provenance(¬e.path, note.format),
});
}
}
results.sort_by(|left, right| {
right
.score
.cmp(&left.score)
.then_with(|| left.path.cmp(&right.path))
.then_with(|| left.chunk_index.cmp(&right.chunk_index))
});
results.truncate(query.limit);
Ok(NotebookRetrievalResponse {
query: query.raw,
strategy: "lexical".to_string(),
results,
})
}
pub fn retrieve_notes_with_query_vector(
config: &AppConfig,
query: &str,
provider: &str,
model_id: &str,
query_vector: &[f32],
) -> Result<NotebookRetrievalResponse, String> {
let query = query.trim();
if query.is_empty() {
return Ok(NotebookRetrievalResponse {
query: String::new(),
strategy: "none".to_string(),
results: Vec::new(),
});
}
let mut results = Vec::new();
for note in list_notes(config)? {
let relative = Path::new(¬e.path);
if !embedding_is_current(config, relative, provider, model_id) {
continue;
}
let chunk_path = chunk_artifact_path(config, relative)
.strip_prefix(&config.derived_dir)
.map(display_path)
.unwrap_or_else(|_| {
chunk_artifact_path(config, relative)
.to_string_lossy()
.to_string()
});
let chunks = load_or_build_chunks(config, ¬e.path, note.format)?;
let vectors = load_embedding_vectors(&embedding_artifact_path(config, relative))?;
for (chunk_index, (chunk, vector)) in chunks.into_iter().zip(vectors).enumerate() {
let similarity = cosine_similarity(query_vector, &vector);
if similarity <= 0.0 {
continue;
}
results.push(NotebookRetrievalResult {
path: note.path.clone(),
format: note_format_string(note.format),
chunk_path: chunk_path.clone(),
chunk_index,
score: (similarity * 1000.0).round().clamp(0.0, 1000.0) as usize,
snippet: chunk.chars().take(240).collect(),
provenance: embedding_provenance(¬e.path, note.format, provider, model_id),
});
}
}
results.sort_by(|left, right| {
right
.score
.cmp(&left.score)
.then_with(|| left.path.cmp(&right.path))
.then_with(|| left.chunk_index.cmp(&right.chunk_index))
});
results.truncate(8);
Ok(NotebookRetrievalResponse {
query: query.to_string(),
strategy: "semantic".to_string(),
results,
})
}
#[derive(Debug, Clone)]
pub struct NotebookEmbeddingInput {
pub path: String,
pub chunks: Vec<String>,
}
pub fn collect_embedding_inputs(config: &AppConfig) -> Result<Vec<NotebookEmbeddingInput>, String> {
let mut inputs = Vec::new();
for note in list_notes(config)? {
let chunks = load_or_build_chunks(config, ¬e.path, note.format)?;
if chunks.is_empty() {
continue;
}
inputs.push(NotebookEmbeddingInput {
path: note.path,
chunks,
});
}
Ok(inputs)
}
pub fn embedding_status(
config: &AppConfig,
provider: &str,
model_id: &str,
) -> Result<NotebookEmbeddingResponse, String> {
let mut items = Vec::new();
for note in list_notes(config)? {
let relative = Path::new(¬e.path);
let artifact = embedding_artifact_path(config, relative);
if !embedding_is_current(config, relative, provider, model_id) {
continue;
}
let payload = std::fs::read_to_string(&artifact)
.map_err(|error| format!("failed to read notebook embeddings: {error}"))?;
let parsed: serde_json::Value = serde_json::from_str(&payload)
.map_err(|error| format!("failed to decode notebook embeddings: {error}"))?;
let vectors = parsed
.get("vectors")
.and_then(|value| value.as_array())
.cloned()
.unwrap_or_default();
let dimensions = vectors
.first()
.and_then(|value| value.as_array())
.map(|vector| vector.len())
.unwrap_or_default();
items.push(NotebookEmbeddingItem {
path: note.path.clone(),
format: note_format_string(note.format),
embedding_path: artifact
.strip_prefix(&config.derived_dir)
.map(display_path)
.unwrap_or_else(|_| artifact.to_string_lossy().to_string()),
provider: provider.to_string(),
model_id: model_id.to_string(),
chunks: vectors.len(),
dimensions,
provenance: embedding_provenance(¬e.path, note.format, provider, model_id),
});
}
Ok(NotebookEmbeddingResponse {
embedded: items.len(),
items,
})
}
pub fn write_note_embeddings(
config: &AppConfig,
note_path: &str,
provider: &str,
model_id: &str,
vectors: &[Vec<f32>],
) -> Result<NotebookEmbeddingItem, String> {
std::fs::create_dir_all(&config.derived_dir)
.map_err(|error| format!("failed to create derived workspace: {error}"))?;
let relative = validate_note_path(note_path)?;
let artifact = embedding_artifact_path(config, &relative);
if let Some(parent) = artifact.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("failed to create notebook embedding directory: {error}"))?;
ensure_path_can_be_parent(parent, &config.derived_dir)?;
}
let payload = serde_json::json!({
"provider": provider,
"model_id": model_id,
"vectors": vectors,
});
std::fs::write(
&artifact,
serde_json::to_vec_pretty(&payload)
.map_err(|error| format!("failed to encode notebook embeddings: {error}"))?,
)
.map_err(|error| format!("failed to write notebook embeddings: {error}"))?;
Ok(NotebookEmbeddingItem {
path: display_path(&relative),
format: note_format_string(format_from_path(&relative)?),
embedding_path: artifact
.strip_prefix(&config.derived_dir)
.map(display_path)
.unwrap_or_else(|_| artifact.to_string_lossy().to_string()),
provider: provider.to_string(),
model_id: model_id.to_string(),
chunks: vectors.len(),
dimensions: vectors
.first()
.map(|vector| vector.len())
.unwrap_or_default(),
provenance: embedding_provenance(
&display_path(&relative),
format_from_path(&relative)?,
provider,
model_id,
),
})
}
pub fn index_status(config: &AppConfig) -> Result<NotebookIndexResponse, String> {
let mut items = Vec::new();
for note in list_notes(config)? {
let artifact = index_artifact_path(config, Path::new(¬e.path));
if !index_is_current(config, Path::new(¬e.path)) {
continue;
}
let bytes = artifact
.metadata()
.map(|metadata| metadata.len() as usize)
.unwrap_or_default();
items.push(NotebookIndexItem {
path: note.path.clone(),
format: note.format,
index_path: artifact
.strip_prefix(&config.derived_dir)
.map(display_path)
.unwrap_or_else(|_| artifact.to_string_lossy().to_string()),
provenance: index_provenance(¬e.path, note.format),
bytes,
});
}
Ok(NotebookIndexResponse {
indexed: items.len(),
items,
})
}
pub fn render_note(
config: &AppConfig,
input: NotebookRenderRequest,
) -> Result<NotebookRenderResponse, String> {
if input.target != "pdf" {
return Err("only pdf render target is currently supported".to_string());
}
let relative = validate_note_path(&input.path)?;
let source = existing_note_path(config, &relative)?;
let format = format_from_path(&relative)?;
if format != NotebookNoteFormat::Typst {
return Err("only Typst notes can be rendered by the Typst adapter".to_string());
}
let adapter = typst_adapter_status();
if adapter.status != "available" {
return Ok(render_response(
config,
relative,
format,
input.target,
adapter.status,
adapter.detail,
));
}
if typst_graph_uses_package_import(config, &relative)? {
return Ok(render_response(
config,
relative,
format,
input.target,
"blocked".to_string(),
"Typst package imports are disabled until the package/cache policy is explicit"
.to_string(),
));
}
let artifact = artifact_path(config, &relative, &input.target)?;
if let Some(parent) = artifact.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("failed to create notebook artifact directory: {error}"))?;
}
let output = run_typst_compile(&config.notebook_dir, &source, &artifact)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let detail = if !stderr.is_empty() { stderr } else { stdout };
return Ok(render_response(
config,
relative,
format,
input.target,
"error".to_string(),
format!("Typst render failed: {detail}"),
));
}
Ok(render_response(
config,
relative,
format,
input.target,
"complete".to_string(),
"Typst render complete".to_string(),
))
}
fn typst_adapter_status() -> NotebookAdapterStatus {
match run_command_with_timeout(
Command::new("typst").arg("--version"),
TYPST_COMMAND_TIMEOUT,
) {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
NotebookAdapterStatus {
id: "typst".to_string(),
label: "Typst".to_string(),
status: "available".to_string(),
version: (!version.is_empty()).then_some(version),
detail: "typst executable was found; render runs only on explicit request"
.to_string(),
}
}
Ok(output) => {
let detail = String::from_utf8_lossy(&output.stderr).trim().to_string();
NotebookAdapterStatus {
id: "typst".to_string(),
label: "Typst".to_string(),
status: "error".to_string(),
version: None,
detail: if detail.is_empty() {
"typst --version failed".to_string()
} else {
detail
},
}
}
Err(error) => {
let missing = error.contains("failed to spawn command");
NotebookAdapterStatus {
id: "typst".to_string(),
label: "Typst".to_string(),
status: if missing { "missing" } else { "error" }.to_string(),
version: None,
detail: if missing {
format!("typst executable was not found: {error}")
} else {
error
},
}
}
}
}
fn collect_notes(
config: &AppConfig,
root: &Path,
current: &Path,
notes: &mut Vec<NotebookNoteSummary>,
) -> Result<(), String> {
for entry in
std::fs::read_dir(current).map_err(|error| format!("failed to list notebook: {error}"))?
{
let entry = entry.map_err(|error| format!("failed to read notebook entry: {error}"))?;
let file_type = entry
.file_type()
.map_err(|error| format!("failed to inspect notebook entry: {error}"))?;
if file_type.is_symlink() {
continue;
}
let path = entry.path();
if file_type.is_dir() {
collect_notes(config, root, &path, notes)?;
continue;
}
if !file_type.is_file() {
continue;
}
let Some(relative) = path.strip_prefix(root).ok().map(PathBuf::from) else {
continue;
};
let Ok(format) = format_from_path(&relative) else {
continue;
};
let content = std::fs::read_to_string(&path).unwrap_or_default();
notes.push(summary_response(config, relative, format, &content));
}
Ok(())
}
fn validate_note_path(raw_path: &str) -> Result<PathBuf, String> {
let trimmed = raw_path.trim().replace('\\', "/");
if trimmed.is_empty() {
return Err("note path is required".to_string());
}
let path = Path::new(&trimmed);
if path.is_absolute() {
return Err("note path must be relative to notebook root".to_string());
}
let mut relative = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(segment) => relative.push(segment),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err("note path must stay inside notebook root".to_string());
}
}
}
format_from_path(&relative)?;
Ok(relative)
}
fn validate_artifact_path(raw_path: &str) -> Result<PathBuf, String> {
let relative = validate_relative_path(raw_path)?;
match relative.extension().and_then(OsStr::to_str) {
Some("pdf") => Ok(relative),
_ => Err("supported notebook artifact extension is .pdf".to_string()),
}
}
fn validate_relative_path(raw_path: &str) -> Result<PathBuf, String> {
let trimmed = raw_path.trim().replace('\\', "/");
if trimmed.is_empty() {
return Err("path is required".to_string());
}
let path = Path::new(&trimmed);
if path.is_absolute() {
return Err("path must be relative".to_string());
}
let mut relative = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(segment) => relative.push(segment),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err("path must stay inside its root".to_string());
}
}
}
Ok(relative)
}
fn existing_note_path(config: &AppConfig, relative: &Path) -> Result<PathBuf, String> {
ensure_notebook_root(config)?;
let target = config.notebook_dir.join(relative);
let canonical_target = target
.canonicalize()
.map_err(|_| format!("note not found: {}", display_path(relative)))?;
ensure_within_root(&canonical_target, &config.notebook_dir)?;
Ok(canonical_target)
}
fn existing_artifact_path(config: &AppConfig, relative: &Path) -> Result<PathBuf, String> {
let root = config.derived_dir.join("notebook-artifacts");
let target = root.join(relative);
let canonical_target = target
.canonicalize()
.map_err(|_| format!("notebook artifact not found: {}", display_path(relative)))?;
ensure_within_root(&canonical_target, &root)?;
Ok(canonical_target)
}
fn creatable_note_path(config: &AppConfig, relative: &Path) -> Result<PathBuf, String> {
ensure_notebook_root(config)?;
let target = config.notebook_dir.join(relative);
if let Some(parent) = target.parent() {
ensure_path_can_be_parent(parent, &config.notebook_dir)?;
}
Ok(target)
}
fn artifact_path(config: &AppConfig, relative: &Path, target: &str) -> Result<PathBuf, String> {
std::fs::create_dir_all(&config.derived_dir)
.map_err(|error| format!("failed to create derived workspace: {error}"))?;
let mut artifact = config.derived_dir.join("notebook-artifacts").join(relative);
artifact.set_extension(target);
if let Some(parent) = artifact.parent() {
ensure_path_can_be_parent(parent, &config.derived_dir)?;
}
Ok(artifact)
}
fn ensure_notebook_root(config: &AppConfig) -> Result<(), String> {
std::fs::create_dir_all(&config.notebook_dir)
.map_err(|error| format!("failed to create notebook root: {error}"))
}
fn ensure_path_can_be_parent(path: &Path, root: &Path) -> Result<(), String> {
let canonical_root = root
.canonicalize()
.map_err(|error| format!("failed to resolve notebook root: {error}"))?;
let mut current = path;
while !current.exists() {
current = current
.parent()
.ok_or_else(|| "note path must stay inside notebook root".to_string())?;
}
let canonical_parent = current
.canonicalize()
.map_err(|error| format!("failed to resolve notebook parent: {error}"))?;
if !canonical_parent.starts_with(canonical_root) {
return Err("note path must stay inside notebook root".to_string());
}
Ok(())
}
fn ensure_within_root(path: &Path, root: &Path) -> Result<(), String> {
let canonical_root = root
.canonicalize()
.map_err(|error| format!("failed to resolve notebook root: {error}"))?;
let canonical_path = path
.canonicalize()
.map_err(|error| format!("failed to resolve notebook path: {error}"))?;
if !canonical_path.starts_with(canonical_root) {
return Err("note path must stay inside notebook root".to_string());
}
Ok(())
}
fn format_from_path(path: &Path) -> Result<NotebookNoteFormat, String> {
match path.extension().and_then(OsStr::to_str) {
Some("md") => Ok(NotebookNoteFormat::Markdown),
Some("typ") => Ok(NotebookNoteFormat::Typst),
_ => Err("supported notebook note extensions are .md and .typ".to_string()),
}
}
fn content_response(
config: &AppConfig,
relative: PathBuf,
format: NotebookNoteFormat,
content: String,
) -> NotebookNoteContent {
let artifact_url = artifact_url(config, &relative, format);
NotebookNoteContent {
path: display_path(&relative),
title: infer_title(&relative, format, &content),
format,
content,
render_status: render_status(config, &relative, format).to_string(),
artifact_url,
}
}
fn summary_response(
config: &AppConfig,
relative: PathBuf,
format: NotebookNoteFormat,
content: &str,
) -> NotebookNoteSummary {
let artifact_url = artifact_url(config, &relative, format);
NotebookNoteSummary {
path: display_path(&relative),
title: infer_title(&relative, format, content),
format,
render_status: render_status(config, &relative, format).to_string(),
artifact_url,
}
}
fn render_response(
config: &AppConfig,
relative: PathBuf,
format: NotebookNoteFormat,
target: String,
status: String,
detail: String,
) -> NotebookRenderResponse {
let artifact = artifact_file_path(config, &relative, &target);
NotebookRenderResponse {
path: display_path(&relative),
format,
target,
artifact_path: artifact
.strip_prefix(&config.derived_dir)
.map(display_path)
.unwrap_or_else(|_| artifact.to_string_lossy().to_string()),
artifact_url: artifact_url(config, &relative, format),
status,
detail,
}
}
fn starter_template(
relative: &Path,
format: NotebookNoteFormat,
_template_id: Option<&str>,
) -> String {
let title = title_from_path(relative);
match format {
NotebookNoteFormat::Markdown => format!("# {title}\n\n"),
NotebookNoteFormat::Typst => {
format!(
"#set document(title: \"{}\")\n\n= {title}\n\n",
escape_typst_string(&title)
)
}
}
}
fn infer_title(relative: &Path, format: NotebookNoteFormat, content: &str) -> String {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match format {
NotebookNoteFormat::Markdown if trimmed.starts_with("# ") => {
return trimmed.trim_start_matches("# ").trim().to_string();
}
NotebookNoteFormat::Typst if trimmed.starts_with('=') => {
return trimmed.trim_start_matches('=').trim().to_string();
}
_ => {}
}
}
title_from_path(relative)
}
fn title_from_path(relative: &Path) -> String {
relative
.file_stem()
.and_then(OsStr::to_str)
.unwrap_or("Untitled")
.replace(['-', '_'], " ")
}
fn render_status(config: &AppConfig, relative: &Path, format: NotebookNoteFormat) -> &'static str {
match format {
NotebookNoteFormat::Markdown => "ready",
NotebookNoteFormat::Typst if artifact_is_current(config, relative, "pdf") => "complete",
NotebookNoteFormat::Typst => "not_rendered",
}
}
fn artifact_url(config: &AppConfig, relative: &Path, format: NotebookNoteFormat) -> Option<String> {
if format != NotebookNoteFormat::Typst {
return None;
}
if !artifact_is_current(config, relative, "pdf") {
return None;
}
let mut artifact_relative = relative.to_path_buf();
artifact_relative.set_extension("pdf");
Some(format!(
"/api/notebook/artifact?path={}",
encode_url_path(&display_path(&artifact_relative))
))
}
fn artifact_file_path(config: &AppConfig, relative: &Path, target: &str) -> PathBuf {
let mut artifact = config.derived_dir.join("notebook-artifacts").join(relative);
artifact.set_extension(target);
artifact
}
fn artifact_is_current(config: &AppConfig, relative: &Path, target: &str) -> bool {
let source = config.notebook_dir.join(relative);
let artifact = artifact_file_path(config, relative, target);
file_is_at_least_as_new(&artifact, &source)
}
fn index_artifact_path(config: &AppConfig, relative: &Path) -> PathBuf {
let mut artifact = config.derived_dir.join("notebook-index").join(relative);
artifact.set_extension("txt");
artifact
}
fn chunk_artifact_path(config: &AppConfig, relative: &Path) -> PathBuf {
let mut artifact = config.derived_dir.join("notebook-chunks").join(relative);
artifact.set_extension("json");
artifact
}
fn embedding_artifact_path(config: &AppConfig, relative: &Path) -> PathBuf {
let mut artifact = config
.derived_dir
.join("notebook-embeddings")
.join(relative);
artifact.set_extension("json");
artifact
}
fn write_indexable_text(
config: &AppConfig,
note_path: &str,
indexable: &str,
) -> Result<String, String> {
std::fs::create_dir_all(&config.derived_dir)
.map_err(|error| format!("failed to create derived workspace: {error}"))?;
let relative = validate_note_path(note_path)?;
let artifact = index_artifact_path(config, &relative);
if let Some(parent) = artifact.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("failed to create notebook index directory: {error}"))?;
ensure_path_can_be_parent(parent, &config.derived_dir)?;
}
std::fs::write(&artifact, indexable)
.map_err(|error| format!("failed to write notebook index: {error}"))?;
Ok(artifact
.strip_prefix(&config.derived_dir)
.map(display_path)
.unwrap_or_else(|_| artifact.to_string_lossy().to_string()))
}
fn write_chunks(config: &AppConfig, note_path: &str, chunks: &[String]) -> Result<String, String> {
std::fs::create_dir_all(&config.derived_dir)
.map_err(|error| format!("failed to create derived workspace: {error}"))?;
let relative = validate_note_path(note_path)?;
let artifact = chunk_artifact_path(config, &relative);
if let Some(parent) = artifact.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("failed to create notebook chunk directory: {error}"))?;
ensure_path_can_be_parent(parent, &config.derived_dir)?;
}
let payload = serde_json::to_string_pretty(
&chunks
.iter()
.enumerate()
.map(|(index, text)| serde_json::json!({ "index": index, "text": text }))
.collect::<Vec<_>>(),
)
.map_err(|error| format!("failed to encode notebook chunks: {error}"))?;
std::fs::write(&artifact, payload)
.map_err(|error| format!("failed to write notebook chunks: {error}"))?;
Ok(artifact
.strip_prefix(&config.derived_dir)
.map(display_path)
.unwrap_or_else(|_| artifact.to_string_lossy().to_string()))
}
fn load_embedding_vectors(path: &Path) -> Result<Vec<Vec<f32>>, String> {
let payload = std::fs::read_to_string(path)
.map_err(|error| format!("failed to read notebook embeddings: {error}"))?;
let parsed: serde_json::Value = serde_json::from_str(&payload)
.map_err(|error| format!("failed to decode notebook embeddings: {error}"))?;
Ok(parsed
.get("vectors")
.and_then(|value| value.as_array())
.cloned()
.unwrap_or_default()
.into_iter()
.map(|vector| {
vector
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter_map(|value| value.as_f64().map(|value| value as f32))
.collect::<Vec<_>>()
})
.collect())
}
fn load_or_normalize_indexable_text(
config: &AppConfig,
note_path: &str,
format: NotebookNoteFormat,
content: &str,
) -> Result<String, String> {
let relative = validate_note_path(note_path)?;
let artifact = index_artifact_path(config, &relative);
if artifact.exists() {
return std::fs::read_to_string(&artifact)
.map_err(|error| format!("failed to read notebook index: {error}"))
.map(|text| {
if index_is_current(config, &relative) {
text
} else {
normalize_indexable_text(format, content)
}
});
}
Ok(normalize_indexable_text(format, content))
}
fn load_or_build_chunks(
config: &AppConfig,
note_path: &str,
format: NotebookNoteFormat,
) -> Result<Vec<String>, String> {
let relative = validate_note_path(note_path)?;
let artifact = chunk_artifact_path(config, &relative);
if chunk_is_current(config, &relative) && artifact.exists() {
return load_chunk_texts(&artifact);
}
let content = read_note(config, note_path)?.content;
let indexable = load_or_normalize_indexable_text(config, note_path, format, &content)?;
Ok(chunk_text(&indexable))
}
fn load_chunk_texts(path: &Path) -> Result<Vec<String>, String> {
let content = std::fs::read_to_string(path)
.map_err(|error| format!("failed to read notebook chunks: {error}"))?;
let items = serde_json::from_str::<Vec<serde_json::Value>>(&content)
.map_err(|error| format!("failed to decode notebook chunks: {error}"))?;
Ok(items
.into_iter()
.filter_map(|item| {
item.get("text")
.and_then(|value| value.as_str())
.map(str::to_string)
})
.collect())
}
fn index_is_current(config: &AppConfig, relative: &Path) -> bool {
let source = config.notebook_dir.join(relative);
let artifact = index_artifact_path(config, relative);
file_is_at_least_as_new(&artifact, &source)
}
fn chunk_is_current(config: &AppConfig, relative: &Path) -> bool {
let source = config.notebook_dir.join(relative);
let index = index_artifact_path(config, relative);
let baseline = if index_is_current(config, relative) {
index
} else {
source
};
let chunks = chunk_artifact_path(config, relative);
file_is_at_least_as_new(&chunks, &baseline)
}
fn embedding_is_current(
config: &AppConfig,
relative: &Path,
provider: &str,
model_id: &str,
) -> bool {
let chunks = chunk_artifact_path(config, relative);
let artifact = embedding_artifact_path(config, relative);
if !file_is_at_least_as_new(&artifact, &chunks) {
return false;
}
let Ok(payload) = std::fs::read_to_string(&artifact) else {
return false;
};
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&payload) else {
return false;
};
parsed.get("provider").and_then(|value| value.as_str()) == Some(provider)
&& parsed.get("model_id").and_then(|value| value.as_str()) == Some(model_id)
}
fn file_is_at_least_as_new(artifact: &Path, source: &Path) -> bool {
let Ok(artifact_modified) = artifact.metadata().and_then(|metadata| metadata.modified()) else {
return false;
};
let Ok(source_modified) = source.metadata().and_then(|metadata| metadata.modified()) else {
return false;
};
artifact_modified >= source_modified
}
fn remove_stale_index(config: &AppConfig, relative: &Path) -> Result<(), String> {
let artifact = index_artifact_path(config, relative);
if artifact.exists() {
std::fs::remove_file(&artifact)
.map_err(|error| format!("failed to remove stale notebook index: {error}"))?;
}
let chunks = chunk_artifact_path(config, relative);
if chunks.exists() {
std::fs::remove_file(&chunks)
.map_err(|error| format!("failed to remove stale notebook chunks: {error}"))?;
}
let embeddings = embedding_artifact_path(config, relative);
if embeddings.exists() {
std::fs::remove_file(&embeddings)
.map_err(|error| format!("failed to remove stale notebook embeddings: {error}"))?;
}
Ok(())
}
fn normalize_indexable_text(format: NotebookNoteFormat, content: &str) -> String {
match format {
NotebookNoteFormat::Markdown => normalize_markdown_text(content),
NotebookNoteFormat::Typst => normalize_typst_text(content),
}
}
fn normalize_markdown_text(content: &str) -> String {
let mut in_fence = false;
content
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("```") {
in_fence = !in_fence;
return None;
}
if in_fence || trimmed.is_empty() {
return None;
}
Some(
trimmed
.trim_start_matches('#')
.trim_start_matches(['-', '*', '>'])
.trim()
.to_string(),
)
})
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn normalize_typst_text(content: &str) -> String {
content
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with("#set")
|| trimmed.starts_with("#import")
|| trimmed.starts_with("#include")
{
return None;
}
Some(
trimmed
.trim_start_matches('=')
.replace(['#', '[', ']', '*', '_'], "")
.trim()
.to_string(),
)
})
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn note_format_string(format: NotebookNoteFormat) -> String {
match format {
NotebookNoteFormat::Markdown => "markdown".to_string(),
NotebookNoteFormat::Typst => "typst".to_string(),
}
}
pub(crate) fn cosine_similarity(left: &[f32], right: &[f32]) -> f64 {
if left.is_empty() || right.is_empty() || left.len() != right.len() {
return 0.0;
}
let mut dot = 0.0_f64;
let mut left_norm = 0.0_f64;
let mut right_norm = 0.0_f64;
for (l, r) in left.iter().zip(right.iter()) {
let l = *l as f64;
let r = *r as f64;
dot += l * r;
left_norm += l * l;
right_norm += r * r;
}
if left_norm == 0.0 || right_norm == 0.0 {
return 0.0;
}
dot / (left_norm.sqrt() * right_norm.sqrt())
}
fn chunk_text(content: &str) -> Vec<String> {
const TARGET_CHARS: usize = 800;
let mut chunks = Vec::new();
let mut current = String::new();
for paragraph in content.split("\n\n") {
let paragraph = paragraph.trim();
if paragraph.is_empty() {
continue;
}
let next_len = current.chars().count() + paragraph.chars().count() + 2;
if !current.is_empty() && next_len > TARGET_CHARS {
chunks.push(current.trim().to_string());
current.clear();
}
if !current.is_empty() {
current.push_str("\n\n");
}
current.push_str(paragraph);
}
if !current.trim().is_empty() {
chunks.push(current.trim().to_string());
}
chunks
}
fn index_provenance(path: &str, format: NotebookNoteFormat) -> String {
let format = match format {
NotebookNoteFormat::Markdown => "markdown",
NotebookNoteFormat::Typst => "typst",
};
format!("source=notebook/{path}; format={format}; extractor=source-normalizer-v1")
}
fn embedding_provenance(
path: &str,
format: NotebookNoteFormat,
provider: &str,
model_id: &str,
) -> String {
let format = match format {
NotebookNoteFormat::Markdown => "markdown",
NotebookNoteFormat::Typst => "typst",
};
format!(
"source=notebook/{path}; format={format}; embedder={provider}/{model_id}; extractor=chunk-embedding-v1"
)
}
fn run_typst_compile(root: &Path, source: &Path, artifact: &Path) -> Result<Output, String> {
let mut command = Command::new("typst");
command
.arg("compile")
.arg("--root")
.arg(root)
.arg(source)
.arg(artifact);
run_command_with_timeout(&mut command, TYPST_COMMAND_TIMEOUT)
}
fn run_command_with_timeout(command: &mut Command, timeout: Duration) -> Result<Output, String> {
let mut child = command
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| format!("failed to spawn command: {error}"))?;
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(_)) => {
return child
.wait_with_output()
.map_err(|error| format!("failed to collect command output: {error}"));
}
Ok(None) if Instant::now() >= deadline => {
let _ = child.kill();
let output = child.wait_with_output().map_err(|error| {
format!("failed to collect timed out command output: {error}")
})?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if stderr.is_empty() {
format!("command timed out after {} seconds", timeout.as_secs())
} else {
format!(
"command timed out after {} seconds: {stderr}",
timeout.as_secs()
)
});
}
Ok(None) => std::thread::sleep(Duration::from_millis(25)),
Err(error) => return Err(format!("failed to poll command: {error}")),
}
}
}
fn uses_typst_package_import(content: &str) -> bool {
content.lines().any(|line| {
let trimmed = line.trim_start();
(trimmed.starts_with("#import") || trimmed.starts_with("#include"))
&& (trimmed.contains("\"@") || trimmed.contains("'@"))
})
}
fn typst_graph_uses_package_import(
config: &AppConfig,
source_relative: &Path,
) -> Result<bool, String> {
ensure_notebook_root(config)?;
let mut stack = vec![source_relative.to_path_buf()];
let mut visited = HashSet::new();
while let Some(relative) = stack.pop() {
if !visited.insert(relative.clone()) {
continue;
}
let source = existing_note_path(config, &relative)?;
let content = std::fs::read_to_string(&source)
.map_err(|error| format!("failed to read Typst source: {error}"))?;
if uses_typst_package_import(&content) {
return Ok(true);
}
for dependency in referenced_local_typst_files(&relative, &content)? {
stack.push(dependency);
}
}
Ok(false)
}
fn referenced_local_typst_files(relative: &Path, content: &str) -> Result<Vec<PathBuf>, String> {
let mut results = Vec::new();
for line in content.lines() {
let trimmed = line.trim_start();
if !(trimmed.starts_with("#include") || trimmed.starts_with("#import")) {
continue;
}
let Some(path_literal) = first_quoted_path(trimmed) else {
continue;
};
if path_literal.starts_with('@') {
continue;
}
let parent = relative.parent().unwrap_or_else(|| Path::new(""));
let candidate = normalize_relative_child(parent, Path::new(path_literal))?;
if candidate.extension().and_then(OsStr::to_str) == Some("typ") {
results.push(candidate);
}
}
Ok(results)
}
fn first_quoted_path(value: &str) -> Option<&str> {
let quote = if value.contains('"') { '"' } else { '\'' };
let (_, tail) = value.split_once(quote)?;
let (path, _) = tail.split_once(quote)?;
Some(path)
}
fn normalize_relative_child(base: &Path, child: &Path) -> Result<PathBuf, String> {
if child.is_absolute() {
return Err("Typst imports must stay relative to notebook root".to_string());
}
let mut combined = PathBuf::from(base);
for component in child.components() {
match component {
Component::Normal(segment) => combined.push(segment),
Component::CurDir => {}
Component::ParentDir => {
if !combined.pop() {
return Err("Typst imports must stay inside notebook root".to_string());
}
}
Component::RootDir | Component::Prefix(_) => {
return Err("Typst imports must stay inside notebook root".to_string());
}
}
}
Ok(combined)
}
fn build_snippet(content: &str, query: &str) -> String {
let query_lower = query.to_lowercase();
for line in content.lines() {
if line.to_lowercase().contains(&query_lower) {
return line.trim().chars().take(160).collect();
}
}
content
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("")
.trim()
.chars()
.take(160)
.collect()
}
fn escape_typst_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn encode_url_path(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
encoded.push(byte as char);
}
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}
fn display_path(path: &Path) -> String {
path.components()
.filter_map(|component| match component {
Component::Normal(segment) => segment.to_str().map(str::to_string),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use soma_studio_core::{
AppConfig, NotebookNoteCreateRequest, NotebookNoteFormat, NotebookRenderRequest,
};
use uuid::Uuid;
use super::{
adapter_statuses, chunk_notes, chunk_status, create_note, index_notes, index_status,
list_notes, read_note, render_note, retrieve_notes_with_query_vector, search_notes,
write_note, write_note_embeddings,
};
#[test]
fn note_roundtrip_supports_markdown_and_typst() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = test_config(&temp_dir);
let markdown = create_note(
&config,
NotebookNoteCreateRequest {
path: "notes/weekly.md".to_string(),
format: NotebookNoteFormat::Markdown,
template_id: None,
},
)
.expect("markdown note");
let typst = create_note(
&config,
NotebookNoteCreateRequest {
path: "reports/weekly.typ".to_string(),
format: NotebookNoteFormat::Typst,
template_id: None,
},
)
.expect("typst note");
assert_eq!(markdown.format, NotebookNoteFormat::Markdown);
assert_eq!(typst.format, NotebookNoteFormat::Typst);
assert_eq!(typst.render_status, "not_rendered");
let saved = write_note(
&config,
soma_studio_core::NotebookNoteWriteRequest {
path: "reports/weekly.typ".to_string(),
content: "= Weekly Report\n\nLocal Typst output".to_string(),
},
)
.expect("write note");
assert_eq!(saved.title, "Weekly Report");
let listed = list_notes(&config).expect("list notes");
assert_eq!(listed.len(), 2);
let read = read_note(&config, "reports/weekly.typ").expect("read note");
assert!(read.content.contains("Local Typst output"));
let searched = search_notes(&config, "Typst").expect("search");
assert_eq!(searched.len(), 1);
assert_eq!(searched[0].path, "reports/weekly.typ");
let indexed = index_notes(&config).expect("index notes");
assert_eq!(indexed.indexed, 2);
assert!(
indexed
.items
.iter()
.any(|item| item.index_path == "notebook-index/reports/weekly.txt")
);
let status = index_status(&config).expect("index status");
assert_eq!(status.indexed, 2);
let search_from_index =
search_notes(&config, "Local Typst output").expect("indexed search");
assert_eq!(search_from_index.len(), 1);
let chunked = chunk_notes(&config).expect("chunk notes");
assert_eq!(chunked.chunked, 2);
assert!(
chunked
.items
.iter()
.any(|item| item.chunk_path == "notebook-chunks/reports/weekly.json")
);
let chunk_status = chunk_status(&config).expect("chunk status");
assert_eq!(chunk_status.chunked, 2);
let _ = std::fs::remove_dir_all(temp_dir);
}
#[test]
fn note_paths_cannot_escape_notebook_root() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = test_config(&temp_dir);
let result = create_note(
&config,
NotebookNoteCreateRequest {
path: "../escape.md".to_string(),
format: NotebookNoteFormat::Markdown,
template_id: None,
},
);
assert!(result.is_err());
let _ = std::fs::remove_dir_all(temp_dir);
}
#[test]
fn typst_render_reports_adapter_state() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = test_config(&temp_dir);
create_note(
&config,
NotebookNoteCreateRequest {
path: "reports/weekly.typ".to_string(),
format: NotebookNoteFormat::Typst,
template_id: None,
},
)
.expect("typst note");
let status = adapter_statuses()
.into_iter()
.find(|adapter| adapter.id == "typst")
.expect("typst adapter");
let result = render_note(
&config,
NotebookRenderRequest {
path: "reports/weekly.typ".to_string(),
target: "pdf".to_string(),
},
);
if status.status == "available" {
let response = result.expect("typst render");
assert_eq!(response.status, "complete");
assert_eq!(
response.artifact_path,
"notebook-artifacts/reports/weekly.pdf"
);
} else {
let response = result.expect("typst render status");
assert_eq!(response.status, status.status);
}
let _ = std::fs::remove_dir_all(temp_dir);
}
#[test]
fn rendered_typst_artifact_updates_note_status() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = test_config(&temp_dir);
create_note(
&config,
NotebookNoteCreateRequest {
path: "reports/weekly.typ".to_string(),
format: NotebookNoteFormat::Typst,
template_id: None,
},
)
.expect("typst note");
let artifact = config
.derived_dir
.join("notebook-artifacts")
.join("reports")
.join("weekly.pdf");
std::fs::create_dir_all(artifact.parent().expect("artifact parent")).expect("artifact dir");
std::fs::write(&artifact, b"%PDF-1.7").expect("artifact");
let note = read_note(&config, "reports/weekly.typ").expect("read note");
assert_eq!(note.render_status, "complete");
assert_eq!(
note.artifact_url.as_deref(),
Some("/api/notebook/artifact?path=reports/weekly.pdf")
);
let indexed = index_notes(&config).expect("index before rewrite");
assert_eq!(indexed.indexed, 1);
let index_artifact = config
.derived_dir
.join("notebook-index")
.join("reports")
.join("weekly.txt");
assert!(index_artifact.exists());
let searched = search_notes(&config, "weekly").expect("search");
assert!(searched[0].artifact_available);
let stale = write_note(
&config,
soma_studio_core::NotebookNoteWriteRequest {
path: "reports/weekly.typ".to_string(),
content: "= Weekly Report\n\nUpdated after render".to_string(),
},
)
.expect("rewrite note");
assert_eq!(stale.render_status, "not_rendered");
assert!(stale.artifact_url.is_none());
assert!(!artifact.exists());
assert!(!index_artifact.exists());
let _ = std::fs::remove_dir_all(temp_dir);
}
#[test]
fn typst_package_imports_are_blocked_before_render() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = test_config(&temp_dir);
create_note(
&config,
NotebookNoteCreateRequest {
path: "reports/package.typ".to_string(),
format: NotebookNoteFormat::Typst,
template_id: None,
},
)
.expect("typst note");
write_note(
&config,
soma_studio_core::NotebookNoteWriteRequest {
path: "reports/package.typ".to_string(),
content: "#import \"@preview/example:0.1.0\": *".to_string(),
},
)
.expect("write package note");
let status = adapter_statuses()
.into_iter()
.find(|adapter| adapter.id == "typst")
.expect("typst adapter");
let response = render_note(
&config,
NotebookRenderRequest {
path: "reports/package.typ".to_string(),
target: "pdf".to_string(),
},
)
.expect("render response");
if status.status == "available" {
assert_eq!(response.status, "blocked");
}
let _ = std::fs::remove_dir_all(temp_dir);
}
#[test]
fn typst_package_imports_are_blocked_in_local_helpers() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = test_config(&temp_dir);
create_note(
&config,
NotebookNoteCreateRequest {
path: "reports/main.typ".to_string(),
format: NotebookNoteFormat::Typst,
template_id: None,
},
)
.expect("typst note");
create_note(
&config,
NotebookNoteCreateRequest {
path: "reports/helper.typ".to_string(),
format: NotebookNoteFormat::Typst,
template_id: None,
},
)
.expect("typst helper");
write_note(
&config,
soma_studio_core::NotebookNoteWriteRequest {
path: "reports/main.typ".to_string(),
content: "#include \"helper.typ\"".to_string(),
},
)
.expect("write main");
write_note(
&config,
soma_studio_core::NotebookNoteWriteRequest {
path: "reports/helper.typ".to_string(),
content: "#import \"@preview/example:0.1.0\": *".to_string(),
},
)
.expect("write helper");
let status = adapter_statuses()
.into_iter()
.find(|adapter| adapter.id == "typst")
.expect("typst adapter");
let response = render_note(
&config,
NotebookRenderRequest {
path: "reports/main.typ".to_string(),
target: "pdf".to_string(),
},
)
.expect("render response");
if status.status == "available" {
assert_eq!(response.status, "blocked");
}
let _ = std::fs::remove_dir_all(temp_dir);
}
#[test]
fn unrelated_typst_package_imports_do_not_block_other_notes() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = test_config(&temp_dir);
create_note(
&config,
NotebookNoteCreateRequest {
path: "reports/main.typ".to_string(),
format: NotebookNoteFormat::Typst,
template_id: None,
},
)
.expect("main typst");
create_note(
&config,
NotebookNoteCreateRequest {
path: "reports/other.typ".to_string(),
format: NotebookNoteFormat::Typst,
template_id: None,
},
)
.expect("other typst");
write_note(
&config,
soma_studio_core::NotebookNoteWriteRequest {
path: "reports/main.typ".to_string(),
content: "= Main\n\nno package import here".to_string(),
},
)
.expect("write main");
write_note(
&config,
soma_studio_core::NotebookNoteWriteRequest {
path: "reports/other.typ".to_string(),
content: "#import \"@preview/example:0.1.0\": *".to_string(),
},
)
.expect("write other");
let status = adapter_statuses()
.into_iter()
.find(|adapter| adapter.id == "typst")
.expect("typst adapter");
let response = render_note(
&config,
NotebookRenderRequest {
path: "reports/main.typ".to_string(),
target: "pdf".to_string(),
},
)
.expect("render response");
if status.status == "available" {
assert_ne!(response.status, "blocked");
}
let _ = std::fs::remove_dir_all(temp_dir);
}
#[test]
fn stale_external_index_is_not_used_for_search() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = test_config(&temp_dir);
create_note(
&config,
NotebookNoteCreateRequest {
path: "notes/topic.md".to_string(),
format: NotebookNoteFormat::Markdown,
template_id: None,
},
)
.expect("markdown note");
write_note(
&config,
soma_studio_core::NotebookNoteWriteRequest {
path: "notes/topic.md".to_string(),
content: "# Topic\n\nold indexed text".to_string(),
},
)
.expect("write note");
index_notes(&config).expect("index notes");
chunk_notes(&config).expect("chunk notes");
std::thread::sleep(std::time::Duration::from_millis(20));
std::fs::write(
config.notebook_dir.join("notes").join("topic.md"),
"# Topic\n\nfresh source text",
)
.expect("external edit");
let old_results = search_notes(&config, "old indexed").expect("old search");
let fresh_results = search_notes(&config, "fresh source").expect("fresh search");
let status = index_status(&config).expect("stale index status");
let chunk_status = chunk_status(&config).expect("stale chunk status");
assert!(old_results.is_empty());
assert_eq!(fresh_results.len(), 1);
assert_eq!(status.indexed, 0);
assert_eq!(chunk_status.chunked, 0);
let _ = std::fs::remove_dir_all(temp_dir);
}
#[test]
fn semantic_retrieval_prefers_closest_embedding() {
let temp_dir =
std::env::temp_dir().join(format!("soma-studio-notebook-{}", Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir).expect("temp dir");
let config = test_config(&temp_dir);
create_note(
&config,
NotebookNoteCreateRequest {
path: "notes/a.md".to_string(),
format: NotebookNoteFormat::Markdown,
template_id: None,
},
)
.expect("note a");
create_note(
&config,
NotebookNoteCreateRequest {
path: "notes/b.md".to_string(),
format: NotebookNoteFormat::Markdown,
template_id: None,
},
)
.expect("note b");
write_note(
&config,
soma_studio_core::NotebookNoteWriteRequest {
path: "notes/a.md".to_string(),
content: "# A\n\nAlpha context".to_string(),
},
)
.expect("write a");
write_note(
&config,
soma_studio_core::NotebookNoteWriteRequest {
path: "notes/b.md".to_string(),
content: "# B\n\nBeta context".to_string(),
},
)
.expect("write b");
index_notes(&config).expect("index");
chunk_notes(&config).expect("chunk");
write_note_embeddings(
&config,
"notes/a.md",
"ollama",
"embed-model",
&[vec![1.0, 0.0]],
)
.expect("embed a");
write_note_embeddings(
&config,
"notes/b.md",
"ollama",
"embed-model",
&[vec![0.0, 1.0]],
)
.expect("embed b");
let retrieval = retrieve_notes_with_query_vector(
&config,
"semantic query",
"ollama",
"embed-model",
&[0.9, 0.1],
)
.expect("semantic retrieval");
assert_eq!(retrieval.strategy, "semantic");
assert_eq!(retrieval.results[0].path, "notes/a.md");
let _ = std::fs::remove_dir_all(temp_dir);
}
fn test_config(temp_dir: &std::path::Path) -> AppConfig {
AppConfig {
app_name: "Soma Studio".to_string(),
bind_addr: "127.0.0.1:0".to_string(),
project_root: temp_dir.to_path_buf(),
data_dir: temp_dir.to_path_buf(),
derived_dir: temp_dir.join("derived"),
notebook_dir: temp_dir.join("notebook"),
user_assets_dir: temp_dir.join("assets"),
db_path: temp_dir.join("test.db"),
web_build_dir: PathBuf::from("unused"),
web_shell_file: PathBuf::from("unused/spa.html"),
}
}
}