use crate::db::{Database, DecisionGraph, QaInteraction, QaSearchResult, RoadmapItem};
use serde::Serialize;
use std::collections::HashMap;
use tiny_http::{Header, Method, Request, Response, Server};
#[derive(Serialize)]
struct ApiResponse<T> {
ok: bool,
data: Option<T>,
error: Option<String>,
}
impl<T: Serialize> ApiResponse<T> {
fn success(data: T) -> Self {
Self {
ok: true,
data: Some(data),
error: None,
}
}
}
const GRAPH_VIEWER_HTML: &str = include_str!("viewer.html");
pub fn start_graph_server(port: u16) -> std::io::Result<()> {
let addr = format!("127.0.0.1:{}", port);
let server = Server::http(&addr)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
let url = format!("http://localhost:{}", port);
eprintln!("\n\x1b[1;32m🌳 Deciduous\x1b[0m");
eprintln!(" Graph viewer: {}", url);
eprintln!(" Press Ctrl+C to stop\n");
for request in server.incoming_requests() {
if let Err(e) = handle_request(request) {
eprintln!("Error: {}", e);
}
}
Ok(())
}
fn handle_request(request: Request) -> std::io::Result<()> {
let url = request.url().to_string();
let path = url.split('?').next().unwrap_or("/");
let method = request.method().clone();
match (&method, path) {
(&Method::Get, "/api/graph") => {
let graph = get_decision_graph();
let json = serde_json::to_string(&ApiResponse::success(graph))?;
let response = Response::from_string(json).with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
request.respond(response)
}
(&Method::Get, "/api/commands") => {
let commands = get_command_log();
let json = serde_json::to_string(&ApiResponse::success(commands))?;
let response = Response::from_string(json).with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
request.respond(response)
}
(&Method::Get, "/api/roadmap") => {
let items = get_roadmap_items();
let json = serde_json::to_string(&ApiResponse::success(items))?;
let response = Response::from_string(json).with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
request.respond(response)
}
(&Method::Get, "/api/git-history") => {
let history = get_git_history();
let json = serde_json::to_string(&ApiResponse::success(history))?;
let response = Response::from_string(json).with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
request.respond(response)
}
(&Method::Post, "/api/roadmap/checkbox") => handle_toggle_checkbox(request),
(&Method::Post, "/api/ask") => handle_ask_question(request),
(&Method::Get, "/api/qa/search") => handle_qa_search(request, &url),
(&Method::Get, "/api/qa") => handle_qa_list(request, &url),
(&Method::Get, p) if p.starts_with("/api/qa/") => handle_qa_get(request, p),
(&Method::Delete, p) if p.starts_with("/api/qa/") => handle_qa_delete(request, p),
(&Method::Get, "/api/documents") => handle_documents_list(request, &url),
(&Method::Get, p) if p.starts_with("/api/documents/file/") => {
handle_document_file(request, p)
}
(&Method::Get, "/api/sync-status") => {
let status = get_sync_status();
let json = serde_json::to_string(&ApiResponse::success(status))?;
let response = Response::from_string(json).with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
request.respond(response)
}
(&Method::Get, _) => {
let response = Response::from_string(GRAPH_VIEWER_HTML)
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"text/html"[..]).unwrap());
request.respond(response)
}
_ => {
let response = Response::from_string("Not found").with_status_code(404);
request.respond(response)
}
}
}
fn get_decision_graph() -> DecisionGraph {
let config = crate::config::Config::load();
let include_config = config.github.commit_repo.is_some();
let config_opt = if include_config { Some(config) } else { None };
match Database::open() {
Ok(db) => db
.get_graph_with_config(config_opt.clone())
.unwrap_or_else(|_| DecisionGraph {
nodes: vec![],
edges: vec![],
config: config_opt.clone(),
themes: vec![],
node_themes: vec![],
documents: vec![],
}),
Err(_) => DecisionGraph {
nodes: vec![],
edges: vec![],
config: config_opt,
themes: vec![],
node_themes: vec![],
documents: vec![],
},
}
}
fn get_command_log() -> Vec<crate::db::CommandLog> {
match Database::open() {
Ok(db) => db.get_recent_commands(100).unwrap_or_default(),
Err(_) => vec![],
}
}
fn get_roadmap_items() -> Vec<RoadmapItem> {
match Database::open() {
Ok(db) => db.get_all_roadmap_items().unwrap_or_default(),
Err(_) => vec![],
}
}
#[derive(Serialize)]
struct GitCommit {
hash: String,
short_hash: String,
author: String,
date: String,
message: String,
files_changed: Option<u32>,
}
fn get_git_history() -> Vec<GitCommit> {
use std::collections::HashSet;
eprintln!("[git-history] Starting get_git_history");
eprintln!("[git-history] Current dir: {:?}", std::env::current_dir());
let nodes = match Database::open() {
Ok(db) => db.get_all_nodes().unwrap_or_default(),
Err(e) => {
eprintln!("[git-history] Database error: {:?}", e);
return vec![];
}
};
eprintln!("[git-history] Got {} nodes from database", nodes.len());
let repo_root = find_git_repo_root();
eprintln!("[git-history] Git repo root: {:?}", repo_root);
let mut hashes = HashSet::new();
for node in &nodes {
if let Some(ref meta_json) = node.metadata_json {
if let Ok(meta) = serde_json::from_str::<serde_json::Value>(meta_json) {
if let Some(commit) = meta.get("commit").and_then(|c| c.as_str()) {
if !commit.is_empty() {
hashes.insert(commit.to_string());
}
}
}
}
}
eprintln!("[git-history] Found {} unique commit hashes", hashes.len());
let mut commits: Vec<GitCommit> = Vec::new();
let mut failed = 0;
for hash in &hashes {
if let Some(commit) = get_git_commit_info(hash, repo_root.as_deref()) {
commits.push(commit);
} else {
failed += 1;
}
}
eprintln!(
"[git-history] Got {} commits, {} failed lookups",
commits.len(),
failed
);
commits.sort_by(|a, b| b.date.cmp(&a.date));
commits
}
#[derive(Serialize)]
struct SyncStatus {
initialized: bool,
pending_events: usize,
events_by_author: HashMap<String, usize>,
checkpoint_time: Option<String>,
checkpoint_nodes: Option<usize>,
checkpoint_edges: Option<usize>,
}
fn get_sync_status() -> SyncStatus {
let deciduous_dir = std::path::PathBuf::from(".deciduous");
let sync_dir = deciduous_dir.join("sync");
if !sync_dir.exists() {
return SyncStatus {
initialized: false,
pending_events: 0,
events_by_author: HashMap::new(),
checkpoint_time: None,
checkpoint_nodes: None,
checkpoint_edges: None,
};
}
let author = crate::events::get_current_author();
let event_log = match crate::events::EventLog::new(&deciduous_dir, author) {
Ok(log) => log,
Err(_) => {
return SyncStatus {
initialized: true,
pending_events: 0,
events_by_author: HashMap::new(),
checkpoint_time: None,
checkpoint_nodes: None,
checkpoint_edges: None,
};
}
};
let events = event_log.get_events_after_checkpoint().unwrap_or_default();
let pending_events = events.len();
let mut events_by_author: HashMap<String, usize> = HashMap::new();
for event in &events {
*events_by_author
.entry(event.author().to_string())
.or_default() += 1;
}
let (checkpoint_time, checkpoint_nodes, checkpoint_edges) = match event_log.load_checkpoint() {
Ok(Some(cp)) => (
Some(cp.created_at.to_rfc3339()),
Some(cp.nodes.len()),
Some(cp.edges.len()),
),
_ => (None, None, None),
};
SyncStatus {
initialized: true,
pending_events,
events_by_author,
checkpoint_time,
checkpoint_nodes,
checkpoint_edges,
}
}
fn find_git_repo_root() -> Option<std::path::PathBuf> {
let current_dir = std::env::current_dir().ok()?;
let mut dir = current_dir.as_path();
loop {
let deciduous_dir = dir.join(".deciduous");
if deciduous_dir.exists() && deciduous_dir.is_dir() {
return Some(dir.to_path_buf());
}
dir = dir.parent()?;
}
}
fn get_git_commit_info(hash: &str, repo_root: Option<&std::path::Path>) -> Option<GitCommit> {
use std::process::Command;
let mut cmd = Command::new("git");
if let Some(root) = repo_root {
cmd.current_dir(root);
}
let output = cmd
.args(["log", "-1", "--format=%H%x00%an%x00%aI%x00%B", hash])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = stdout.trim().split('\x00').collect();
if parts.len() < 4 {
return None;
}
let message = parts[3].trim().to_string();
let mut files_cmd = Command::new("git");
if let Some(root) = repo_root {
files_cmd.current_dir(root);
}
let files_output = files_cmd
.args(["diff-tree", "--no-commit-id", "--name-only", "-r", hash])
.output()
.ok();
let files_changed = files_output.and_then(|o| {
if o.status.success() {
let count = String::from_utf8_lossy(&o.stdout).trim().lines().count();
Some(count as u32)
} else {
None
}
});
Some(GitCommit {
hash: parts[0].to_string(),
short_hash: parts[0].chars().take(7).collect(),
author: parts[1].to_string(),
date: parts[2].to_string(),
message,
files_changed,
})
}
#[derive(serde::Deserialize)]
struct ToggleCheckboxRequest {
item_id: i32,
checkbox_state: String,
}
#[derive(serde::Deserialize)]
struct AskRequest {
question: String,
context: Option<AskContext>,
}
#[derive(serde::Deserialize, serde::Serialize)]
struct AskContext {
selected_node_id: Option<i32>,
visible_node_ids: Option<Vec<i32>>,
current_branch: Option<String>,
narrative: Option<NarrativeContext>,
}
#[derive(serde::Deserialize, serde::Serialize)]
struct NarrativeContext {
name: String,
root_id: i32,
#[serde(default)]
node_ids: Vec<i32>,
#[serde(default)]
pivots: Vec<PivotContext>,
#[serde(default)]
github_links: Vec<GithubLinkContext>,
}
#[derive(serde::Deserialize, serde::Serialize)]
struct PivotContext {
revisit_id: i32,
observation_ids: Vec<i32>,
superseded_ids: Vec<i32>,
new_approach_ids: Vec<i32>,
}
#[derive(serde::Deserialize, serde::Serialize)]
struct GithubLinkContext {
#[serde(rename = "type")]
link_type: String,
identifier: String,
repo: String,
}
#[derive(serde::Serialize)]
struct AskResponse {
answer: String,
}
fn handle_ask_question(mut request: Request) -> std::io::Result<()> {
use std::io::Write;
use std::process::{Command, Stdio};
let mut body = String::new();
if let Err(e) = request.as_reader().read_to_string(&mut body) {
let json = serde_json::to_string(&ApiResponse::<()> {
ok: false,
data: None,
error: Some(format!("Failed to read body: {}", e)),
})?;
let response = Response::from_string(json)
.with_status_code(400)
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
return request.respond(response);
}
let req: AskRequest = match serde_json::from_str(&body) {
Ok(r) => r,
Err(e) => {
let json = serde_json::to_string(&ApiResponse::<()> {
ok: false,
data: None,
error: Some(format!("Invalid JSON: {}", e)),
})?;
let response = Response::from_string(json)
.with_status_code(400)
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
return request.respond(response);
}
};
let prompt = build_claude_prompt(&req);
let mut child = match Command::new("claude")
.arg("-p")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(e) => {
let error = if e.kind() == std::io::ErrorKind::NotFound {
"Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"
.to_string()
} else {
format!("Failed to spawn claude: {}", e)
};
let json = serde_json::to_string(&ApiResponse::<AskResponse> {
ok: false,
data: None,
error: Some(error),
})?;
let response = Response::from_string(json)
.with_status_code(500)
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
return request.respond(response);
}
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(prompt.as_bytes());
}
let output = child.wait_with_output();
let (json, status) = match output {
Ok(output) => {
if output.status.success() {
let answer = String::from_utf8_lossy(&output.stdout).to_string();
if let Ok(db) = Database::open() {
let context_json = req
.context
.as_ref()
.and_then(|ctx| serde_json::to_string(ctx).ok());
if let Err(e) = db.save_qa_interaction(
&req.question,
&prompt,
&answer,
context_json.as_deref(),
) {
eprintln!("Warning: Failed to save Q&A interaction: {}", e);
}
}
(
serde_json::to_string(&ApiResponse::success(AskResponse {
answer: answer.clone(),
}))?,
200,
)
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let error = if stderr.is_empty() {
"Claude command failed with no error message".to_string()
} else {
stderr
};
(
serde_json::to_string(&ApiResponse::<AskResponse> {
ok: false,
data: None,
error: Some(error),
})?,
500,
)
}
}
Err(e) => {
let error = if e.kind() == std::io::ErrorKind::NotFound {
"Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"
.to_string()
} else {
format!("Failed to execute claude: {}", e)
};
(
serde_json::to_string(&ApiResponse::<AskResponse> {
ok: false,
data: None,
error: Some(error),
})?,
500,
)
}
};
let response = Response::from_string(json)
.with_status_code(status)
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap());
request.respond(response)
}
fn build_claude_prompt(req: &AskRequest) -> String {
let mut prompt = String::new();
let is_archaeology = req
.context
.as_ref()
.is_some_and(|ctx| ctx.narrative.is_some());
if is_archaeology {
prompt.push_str(r#"You are an expert in this codebase. You have meticulously crafted a narrative graph that lets someone understand and explore the entire decision history you have built up in order to understand the *why* of certain pieces of code. This was built using your archaeology tool.
The narrative graph captures:
- **Goals**: What we set out to accomplish
- **Decisions**: Choices made along the way
- **Actions**: Implementation steps taken
- **Observations**: What we learned during the process
- **Pivots**: Where we changed approach based on new information
- **Outcomes**: Results of our decisions
When answering questions:
1. Ground your answers in the specific nodes and decisions from the narrative
2. Explain the *why* behind decisions, not just the *what*
3. Highlight pivots and course corrections - these often contain the most valuable insights
4. Reference GitHub artifacts (commits, PRs, issues) when relevant
5. If the narrative doesn't contain enough information, say so explicitly
Format your response in Markdown.
---
"#);
} else {
prompt.push_str(r#"You are to use deciduous to answer questions about the codebase and decision graph.
You can use your skills/tools/commands to query the graph with the various deciduous helpers.
You can also use SQLite directly to query the graph database at .deciduous/deciduous.db to answer questions and traverse relationships.
The schema has these tables:
- decision_nodes (id, node_type, title, description, status, created_at, updated_at, metadata_json, change_id)
- metadata_json may contain: branch, commit, files, confidence, prompt
- decision_edges (id, from_node_id, to_node_id, edge_type, weight, rationale, created_at, from_change_id, to_change_id)
Example queries:
- List all nodes: SELECT id, title, node_type, status FROM decision_nodes;
- Find commits: SELECT id, title, json_extract(metadata_json, '$.commit') as commit FROM decision_nodes WHERE metadata_json LIKE '%commit%';
- Get edges for a node: SELECT e.*, n.title FROM decision_edges e JOIN decision_nodes n ON e.to_node_id = n.id WHERE e.from_node_id = ?;
Make sure to be detailed in your response, and format it in markdown.
IMPORTANT: If information is missing or incomplete, tell the user explicitly and suggest a prompt they could give Claude to fill in that information using public sources or codebase exploration.
---
"#);
}
if let Some(ctx) = &req.context {
prompt.push_str("Context from deciduous decision graph:\n\n");
if let Some(node_id) = ctx.selected_node_id {
if let Ok(db) = Database::open() {
if let Ok(Some(node)) = db.get_node(node_id) {
prompt.push_str(&format!(
"Currently viewing node #{}: \"{}\" ({})\n",
node.id, node.title, node.node_type
));
if let Some(desc) = &node.description {
prompt.push_str(&format!("Description: {}\n", desc));
}
if let Some(meta_str) = &node.metadata_json {
if let Ok(meta) = serde_json::from_str::<serde_json::Value>(meta_str) {
if let Some(branch) = meta.get("branch").and_then(|v| v.as_str()) {
prompt.push_str(&format!("Branch: {}\n", branch));
}
if let Some(prompt_text) = meta.get("prompt").and_then(|v| v.as_str()) {
prompt.push_str(&format!("Original prompt: {}\n", prompt_text));
}
}
}
prompt.push('\n');
}
}
}
if let Some(visible_ids) = &ctx.visible_node_ids {
prompt.push_str(&format!(
"User is viewing {} nodes in the graph.\n\n",
visible_ids.len()
));
}
if let Some(branch) = &ctx.current_branch {
prompt.push_str(&format!("Current git branch: {}\n\n", branch));
}
if let Some(narrative) = &ctx.narrative {
prompt.push_str("## Narrative Context (Archaeology View)\n\n");
prompt.push_str(&format!("**Narrative:** {}\n", narrative.name));
prompt.push_str(&format!(
"**Scope:** {} nodes, starting from node #{}\n\n",
narrative.node_ids.len(),
narrative.root_id
));
if let Ok(db) = Database::open() {
prompt.push_str("### Nodes in this narrative:\n\n");
for node_id in &narrative.node_ids {
if let Ok(Some(node)) = db.get_node(*node_id) {
let status_marker = match node.status.as_str() {
"superseded" => " [SUPERSEDED]",
"abandoned" => " [ABANDONED]",
_ => "",
};
prompt.push_str(&format!(
"- **#{}** ({}{}) {}\n",
node.id, node.node_type, status_marker, node.title
));
if let Some(desc) = &node.description {
if !desc.is_empty() {
prompt.push_str(&format!(" - {}\n", desc));
}
}
}
}
prompt.push('\n');
}
if !narrative.pivots.is_empty() {
prompt.push_str("### Pivots (approach changes):\n\n");
for (i, pivot) in narrative.pivots.iter().enumerate() {
prompt.push_str(&format!(
"**Pivot {}:** Revisit node #{}\n",
i + 1,
pivot.revisit_id
));
if !pivot.observation_ids.is_empty() {
prompt.push_str(&format!(
"- Triggered by observations: {}\n",
pivot
.observation_ids
.iter()
.map(|id| format!("#{}", id))
.collect::<Vec<_>>()
.join(", ")
));
}
if !pivot.superseded_ids.is_empty() {
prompt.push_str(&format!(
"- Superseded nodes: {}\n",
pivot
.superseded_ids
.iter()
.map(|id| format!("#{}", id))
.collect::<Vec<_>>()
.join(", ")
));
}
if !pivot.new_approach_ids.is_empty() {
prompt.push_str(&format!(
"- New approach nodes: {}\n",
pivot
.new_approach_ids
.iter()
.map(|id| format!("#{}", id))
.collect::<Vec<_>>()
.join(", ")
));
}
prompt.push('\n');
}
}
if !narrative.github_links.is_empty() {
prompt.push_str("### GitHub Artifacts:\n\n");
for link in &narrative.github_links {
let link_desc = match link.link_type.as_str() {
"commit" => format!(
"Commit {}",
&link.identifier[..7.min(link.identifier.len())]
),
"pr" => format!("PR #{}", link.identifier),
"issue" => format!("Issue #{}", link.identifier),
_ => format!("{} {}", link.link_type, link.identifier),
};
prompt.push_str(&format!("- {} ({})\n", link_desc, link.repo));
}
prompt.push('\n');
}
prompt.push_str("---\n\n");
}
}
prompt.push_str("User question: ");
prompt.push_str(&req.question);
prompt
}
fn handle_toggle_checkbox(mut request: Request) -> std::io::Result<()> {
let mut body = String::new();
if let Err(e) = request.as_reader().read_to_string(&mut body) {
let json = serde_json::to_string(&ApiResponse::<()> {
ok: false,
data: None,
error: Some(format!("Failed to read body: {}", e)),
})?;
let response = Response::from_string(json)
.with_status_code(400)
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
return request.respond(response);
}
let req: ToggleCheckboxRequest = match serde_json::from_str(&body) {
Ok(r) => r,
Err(e) => {
let json = serde_json::to_string(&ApiResponse::<()> {
ok: false,
data: None,
error: Some(format!("Invalid JSON: {}", e)),
})?;
let response = Response::from_string(json)
.with_status_code(400)
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
return request.respond(response);
}
};
let result = match Database::open() {
Ok(db) => db.update_roadmap_item_checkbox(req.item_id, &req.checkbox_state),
Err(e) => Err(e),
};
let (json, status) = match result {
Ok(()) => (serde_json::to_string(&ApiResponse::success(true))?, 200),
Err(e) => (
serde_json::to_string(&ApiResponse::<bool> {
ok: false,
data: None,
error: Some(format!("Database error: {}", e)),
})?,
500,
),
};
let response = Response::from_string(json)
.with_status_code(status)
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap());
request.respond(response)
}
fn parse_query_params(url: &str) -> HashMap<String, String> {
let mut params = HashMap::new();
if let Some(query_start) = url.find('?') {
let query = &url[query_start + 1..];
for pair in query.split('&') {
if let Some(eq_pos) = pair.find('=') {
let key = &pair[..eq_pos];
let value = &pair[eq_pos + 1..];
let decoded = value.replace("%20", " ").replace("+", " ");
params.insert(key.to_string(), decoded);
}
}
}
params
}
#[derive(Serialize)]
struct QaListResponse {
items: Vec<QaInteraction>,
total: i64,
}
fn handle_qa_search(request: Request, url: &str) -> std::io::Result<()> {
let params = parse_query_params(url);
let query = params.get("q").map(|s| s.as_str()).unwrap_or("");
let limit: i32 = params
.get("limit")
.and_then(|s| s.parse().ok())
.unwrap_or(20);
if query.is_empty() {
let json = serde_json::to_string(&ApiResponse::<Vec<QaSearchResult>> {
ok: false,
data: None,
error: Some("Missing search query parameter 'q'".to_string()),
})?;
let response = Response::from_string(json)
.with_status_code(400)
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
return request.respond(response);
}
let results = match Database::open() {
Ok(db) => db.search_qa_interactions(query, limit).unwrap_or_default(),
Err(_) => vec![],
};
let json = serde_json::to_string(&ApiResponse::success(results))?;
let response = Response::from_string(json)
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap());
request.respond(response)
}
fn handle_qa_list(request: Request, url: &str) -> std::io::Result<()> {
let params = parse_query_params(url);
let offset: i64 = params
.get("offset")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let limit: i64 = params
.get("limit")
.and_then(|s| s.parse().ok())
.unwrap_or(20);
let (items, total) = match Database::open() {
Ok(db) => {
let items = db
.get_qa_interactions_paginated(offset, limit)
.unwrap_or_default();
let total = db.count_qa_interactions().unwrap_or(0);
(items, total)
}
Err(_) => (vec![], 0),
};
let json = serde_json::to_string(&ApiResponse::success(QaListResponse { items, total }))?;
let response = Response::from_string(json)
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap());
request.respond(response)
}
fn handle_qa_get(request: Request, path: &str) -> std::io::Result<()> {
let id: i32 = match path.strip_prefix("/api/qa/").and_then(|s| s.parse().ok()) {
Some(id) => id,
None => {
let json = serde_json::to_string(&ApiResponse::<QaInteraction> {
ok: false,
data: None,
error: Some("Invalid Q&A ID".to_string()),
})?;
let response = Response::from_string(json)
.with_status_code(400)
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
return request.respond(response);
}
};
let result = match Database::open() {
Ok(db) => db.get_qa_interaction(id),
Err(e) => Err(e),
};
match result {
Ok(Some(interaction)) => {
let json = serde_json::to_string(&ApiResponse::success(interaction))?;
let response = Response::from_string(json).with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
request.respond(response)
}
Ok(None) => {
let json = serde_json::to_string(&ApiResponse::<QaInteraction> {
ok: false,
data: None,
error: Some("Q&A interaction not found".to_string()),
})?;
let response = Response::from_string(json)
.with_status_code(404)
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
request.respond(response)
}
Err(e) => {
let json = serde_json::to_string(&ApiResponse::<QaInteraction> {
ok: false,
data: None,
error: Some(format!("Database error: {}", e)),
})?;
let response = Response::from_string(json)
.with_status_code(500)
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
request.respond(response)
}
}
}
fn handle_qa_delete(request: Request, path: &str) -> std::io::Result<()> {
let id: i32 = match path.strip_prefix("/api/qa/").and_then(|s| s.parse().ok()) {
Some(id) => id,
None => {
let json = serde_json::to_string(&ApiResponse::<bool> {
ok: false,
data: None,
error: Some("Invalid Q&A ID".to_string()),
})?;
let response = Response::from_string(json)
.with_status_code(400)
.with_header(
Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap(),
);
return request.respond(response);
}
};
let result = match Database::open() {
Ok(db) => db.soft_delete_qa_interaction(id),
Err(e) => Err(e),
};
let (json, status) = match result {
Ok(()) => (serde_json::to_string(&ApiResponse::success(true))?, 200),
Err(e) => (
serde_json::to_string(&ApiResponse::<bool> {
ok: false,
data: None,
error: Some(format!("Database error: {}", e)),
})?,
500,
),
};
let response = Response::from_string(json)
.with_status_code(status)
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap());
request.respond(response)
}
fn handle_documents_list(request: Request, url: &str) -> std::io::Result<()> {
let params = parse_query_params(url);
let node_id: Option<i32> = params.get("node_id").and_then(|s| s.parse().ok());
let documents = match Database::open() {
Ok(db) => db.get_node_documents(node_id, false).unwrap_or_default(),
Err(_) => vec![],
};
let json = serde_json::to_string(&ApiResponse::success(documents))?;
let response = Response::from_string(json)
.with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap());
request.respond(response)
}
fn handle_document_file(request: Request, path: &str) -> std::io::Result<()> {
let doc_id: i32 = match path
.strip_prefix("/api/documents/file/")
.and_then(|s| s.parse().ok())
{
Some(id) => id,
None => {
let response = Response::from_string("Invalid document ID").with_status_code(400);
return request.respond(response);
}
};
let db = match Database::open() {
Ok(db) => db,
Err(_) => {
let response = Response::from_string("Database error").with_status_code(500);
return request.respond(response);
}
};
let doc = match db.get_document(doc_id) {
Ok(Some(d)) => d,
Ok(None) => {
let response = Response::from_string("Document not found").with_status_code(404);
return request.respond(response);
}
Err(_) => {
let response = Response::from_string("Database error").with_status_code(500);
return request.respond(response);
}
};
let file_path = std::path::PathBuf::from(".deciduous")
.join("documents")
.join(&doc.storage_filename);
if !file_path.exists() {
let response = Response::from_string("File not found on disk").with_status_code(404);
return request.respond(response);
}
let file_data = match std::fs::read(&file_path) {
Ok(data) => data,
Err(_) => {
let response = Response::from_string("Failed to read file").with_status_code(500);
return request.respond(response);
}
};
let content_type = doc.mime_type.as_str();
let response = Response::from_data(file_data)
.with_header(Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()).unwrap())
.with_header(
Header::from_bytes(
&b"Content-Disposition"[..],
format!("inline; filename=\"{}\"", doc.original_filename).as_bytes(),
)
.unwrap(),
);
request.respond(response)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_api_response_success() {
let response: ApiResponse<String> = ApiResponse::success("hello".to_string());
assert!(response.ok);
assert_eq!(response.data, Some("hello".to_string()));
assert!(response.error.is_none());
}
#[test]
fn test_api_response_success_with_vec() {
let data = vec![1, 2, 3];
let response: ApiResponse<Vec<i32>> = ApiResponse::success(data.clone());
assert!(response.ok);
assert_eq!(response.data, Some(data));
}
#[test]
fn test_api_response_serializes_to_json() {
let response: ApiResponse<String> = ApiResponse::success("test".to_string());
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("\"ok\":true"));
assert!(json.contains("\"data\":\"test\""));
assert!(json.contains("\"error\":null"));
}
#[test]
fn test_api_response_with_complex_data() {
#[derive(Serialize, PartialEq, Debug)]
struct TestData {
name: String,
count: u32,
}
let data = TestData {
name: "test".to_string(),
count: 42,
};
let response = ApiResponse::success(data);
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("\"name\":\"test\""));
assert!(json.contains("\"count\":42"));
}
#[test]
fn test_viewer_html_is_valid() {
assert!(
GRAPH_VIEWER_HTML.contains("<!DOCTYPE html>") || GRAPH_VIEWER_HTML.contains("<html")
);
assert!(GRAPH_VIEWER_HTML.contains("</html>"));
}
#[test]
fn test_viewer_html_has_react() {
assert!(
GRAPH_VIEWER_HTML.contains("React") || GRAPH_VIEWER_HTML.contains("react"),
"Viewer should include React"
);
}
}