use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use serde_json::{json, Value};
mod calendar;
mod codegen;
mod facts;
mod file_ops;
mod git;
mod github;
mod gmail;
mod ide;
mod markets;
mod mission;
mod notes;
mod recall;
mod registry;
mod schedule;
mod search;
mod shell;
mod telegram;
mod todos;
mod web_search;
pub use codegen::{extract_user_prompt_paths, set_current_turn_paths};
type SchemasFn = fn() -> Vec<Value>;
type DispatchFn = fn(&str, &str) -> Option<Result<String, String>>;
const GROUPS: &[(SchemasFn, DispatchFn)] = &[
(calendar::schemas, calendar::dispatch),
(codegen::schemas, codegen::dispatch),
(facts::schemas, facts::dispatch),
(file_ops::schemas, file_ops::dispatch),
(git::schemas, git::dispatch),
(github::schemas, github::dispatch),
(gmail::schemas, gmail::dispatch),
(ide::schemas, ide::dispatch),
(markets::schemas, markets::dispatch),
(mission::schemas, mission::dispatch),
(notes::schemas, notes::dispatch),
(recall::schemas, recall::dispatch),
(registry::schemas, registry::dispatch),
(schedule::schemas, schedule::dispatch),
(search::schemas, search::dispatch),
(shell::schemas, shell::dispatch),
(telegram::schemas, telegram::dispatch),
(todos::schemas, todos::dispatch),
(web_search::schemas, web_search::dispatch),
];
fn tools_json_cached() -> &'static Value {
static TOOLS_JSON: OnceLock<Value> = OnceLock::new();
TOOLS_JSON.get_or_init(build_tools_json)
}
fn build_tools_json() -> Value {
let mut tools: Vec<Value> = json!([
{
"type": "function",
"function": {
"name": "get_current_time",
"description": "Current date, time, weekday, timezone.",
"parameters": { "type": "object", "properties": {}, "required": [] }
}
},
{
"type": "function",
"function": {
"name": "load_workspace_rules",
"description": "Load CLAUDETTE.md / .claudette/instructions.md from the project ancestor chain. Call when project conventions matter for the answer.",
"parameters": { "type": "object", "properties": {}, "required": [] }
}
},
{
"type": "function",
"function": {
"name": "get_capabilities",
"description": "Show the secretary's config, available tools, and limits. Use for 'what can you do' questions.",
"parameters": { "type": "object", "properties": {}, "required": [] }
}
},
])
.as_array()
.cloned()
.unwrap_or_default();
for (schemas_fn, _) in GROUPS {
tools.extend(schemas_fn());
}
Value::Array(tools)
}
#[must_use]
pub fn secretary_tools_json() -> Value {
tools_json_cached().clone()
}
pub fn dispatch_tool(name: &str, input: &str) -> Result<String, String> {
for (_, dispatch_fn) in GROUPS {
if let Some(result) = dispatch_fn(name, input) {
return result;
}
}
match name {
"get_current_time" => Ok(run_get_current_time()),
"load_workspace_rules" => Ok(run_load_workspace_rules()),
"get_capabilities" => Ok(run_get_capabilities()),
"add_numbers" => run_add_numbers(input),
other => Err(format!("unknown tool: {other}")),
}
}
fn run_get_current_time() -> String {
use chrono::{Datelike, Timelike};
let now = chrono::Local::now();
let dow = now.weekday().number_from_monday();
let human = {
let hour12 = match now.hour() % 12 {
0 => 12,
h => h,
};
let ampm = if now.hour() < 12 { "AM" } else { "PM" };
format!(
"{}, {} {}, {} at {}:{:02} {}",
now.format("%A"),
now.format("%B"),
now.day(),
now.year(),
hour12,
now.minute(),
ampm,
)
};
json!({
"iso8601": now.to_rfc3339(),
"weekday": now.format("%A").to_string(),
"weekday_num": dow,
"date": now.format("%Y-%m-%d").to_string(),
"time": now.format("%H:%M:%S").to_string(),
"timezone": now.format("%:z").to_string(),
"unix_timestamp": now.timestamp(),
"human": human,
})
.to_string()
}
fn run_add_numbers(input: &str) -> Result<String, String> {
let v: Value = serde_json::from_str(input)
.map_err(|e| format!("add_numbers: invalid JSON input ({e}): {input}"))?;
let a = v
.get("a")
.and_then(Value::as_f64)
.ok_or_else(|| format!("add_numbers: missing or non-numeric 'a' in {input}"))?;
let b = v
.get("b")
.and_then(Value::as_f64)
.ok_or_else(|| format!("add_numbers: missing or non-numeric 'b' in {input}"))?;
Ok(json!({ "a": a, "b": b, "sum": a + b }).to_string())
}
fn run_load_workspace_rules() -> String {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
match crate::prompt_runtime::ProjectContext::discover(&cwd, date) {
Ok(ctx) if !ctx.instruction_files.is_empty() => {
let blocks: Vec<Value> = ctx
.instruction_files
.iter()
.map(|f| {
let content: String = f.content.chars().take(2000).collect();
json!({
"path": f.path.display().to_string(),
"content": content,
})
})
.collect();
json!({ "files": blocks }).to_string()
}
_ => json!({
"files": [],
"note": "no CLAUDETTE.md or .claudette/instructions.md found in cwd or its ancestors",
})
.to_string(),
}
}
pub(super) fn user_home() -> PathBuf {
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(home)
}
pub(super) fn claudette_home() -> PathBuf {
user_home().join(".claudette")
}
pub(super) fn files_dir() -> PathBuf {
claudette_home().join("files")
}
pub(super) fn ensure_dir(path: &Path) -> Result<(), String> {
fs::create_dir_all(path).map_err(|e| format!("create dir {}: {e}", path.display()))
}
pub(super) fn file_url_for(path: &Path) -> String {
let s = path.display().to_string().replace('\\', "/");
let s = s.trim_start_matches('/');
format!("file:///{s}")
}
fn run_get_capabilities() -> String {
let registry = crate::tool_groups::ToolRegistry::new();
let core_names = registry.core_tool_names();
let groups_summary: Vec<Value> = crate::tool_groups::ToolGroup::all()
.iter()
.map(|g| {
json!({
"name": g.name(),
"summary": g.summary(),
"tools": registry.group_tool_names(*g),
})
})
.collect();
let total_tools = core_names.len()
+ crate::tool_groups::ToolGroup::all()
.iter()
.map(|g| registry.group_tool_names(*g).len())
.sum::<usize>();
json!({
"name": "Claudette",
"kind": "personal AI secretary",
"model": crate::run::current_model(),
"runtime": "crate::ConversationRuntime over Ollama /api/chat",
"context_window": {
"num_ctx_tokens": crate::api::current_num_ctx(),
"num_predict_tokens": crate::api::current_num_predict(),
"auto_compaction_threshold_tokens": crate::run::compact_threshold(),
"notes": "Auto-compaction summarises old turns when cumulative input tokens cross the threshold; the most recent turns stay verbatim. A char-based sliding-window truncator inside api.rs is the in-iteration safety net.",
},
"tools": {
"total": total_tools,
"core": core_names,
"optional_groups": groups_summary,
"note": "Optional group tools are only advertised after you call enable_tools(group) — they cut the per-turn schema cost when unused.",
},
"sandbox": {
"read": "user $HOME (/home/<user> or C:\\Users\\<user>) — symlinks/junctions resolved as such, system dirs not blocked but ACL-protected anyway",
"write": files_dir().display().to_string(),
"rationale": "writes are sandboxed to ~/.claudette/files/ so the secretary cannot overwrite the user's real documents by accident or hallucination",
},
"storage": {
"notes": notes::notes_dir().display().to_string(),
"todos": todos::todos_path().display().to_string(),
"scratch_files": files_dir().display().to_string(),
"session": crate::run::default_session_path().display().to_string(),
},
"version": env!("CARGO_PKG_VERSION"),
})
.to_string()
}
pub(super) const MAX_FILE_BYTES: usize = 100 * 1024;
pub(crate) fn expand_tilde(input: &str) -> PathBuf {
if let Some(rest) = input
.strip_prefix("~/")
.or_else(|| input.strip_prefix("~\\"))
{
user_home().join(rest)
} else if input == "~" {
user_home()
} else {
PathBuf::from(input)
}
}
pub(super) fn normalize_path(path: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for comp in path.components() {
match comp {
Component::ParentDir => {
let popped =
matches!(out.components().next_back(), Some(Component::Normal(_))) && out.pop();
if !popped {
out.push("..");
}
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
if out.as_os_str().is_empty() {
PathBuf::from(".")
} else {
out
}
}
#[allow(clippy::unnecessary_wraps)]
fn resolve_input_path(input: &str) -> Result<PathBuf, String> {
let expanded = expand_tilde(input);
if expanded.is_absolute() {
return Ok(normalize_path(&expanded));
}
let cwd_joined = crate::missions::active_cwd().join(&expanded);
let cwd_norm = normalize_path(&cwd_joined);
if crate::missions::active_mission().is_none() && !cwd_norm.exists() {
if let Some(resolved) = resolve_via_workspace_roots(&expanded) {
return Ok(resolved);
}
}
Ok(cwd_norm)
}
fn resolve_via_workspace_roots(relative: &Path) -> Option<PathBuf> {
let roots = parse_workspace_env();
if roots.is_empty() {
return None;
}
if let Ok(cwd) = std::env::current_dir() {
let cwd_norm = normalize_path(&cwd);
if roots
.iter()
.any(|r| cwd_norm.starts_with(normalize_path(r)))
{
return None;
}
}
for root in &roots {
let candidate = normalize_path(&root.join(relative));
if candidate.exists() {
return Some(candidate);
}
}
None
}
#[derive(Debug, Clone)]
pub(crate) struct WorkspaceRoots {
pub home: PathBuf,
pub cwd: Option<PathBuf>,
pub workspace: Vec<PathBuf>,
}
impl WorkspaceRoots {
pub fn from_env() -> Self {
Self {
home: normalize_path(&user_home()),
cwd: std::env::current_dir().ok(),
workspace: parse_workspace_env(),
}
}
#[must_use]
pub fn startup_diagnostics(&self) -> Vec<String> {
let mut warnings = Vec::new();
if let Some(cwd) = &self.cwd {
let cwd_under_home = cwd.starts_with(&self.home);
if !cwd_under_home && self.workspace.is_empty() {
warnings.push(format!(
"Working directory ({}) is outside $HOME ({}) and \
CLAUDETTE_WORKSPACE is not set. File reads will be \
restricted to $HOME — `read_file` and `list_dir` \
will refuse paths under the working directory. \
Export CLAUDETTE_WORKSPACE=\"$(pwd)\" if you intended \
the brain to read files here.",
cwd.display(),
self.home.display(),
));
}
}
warnings
}
}
#[must_use]
pub(crate) fn default_workspace_root() -> Option<PathBuf> {
let roots = parse_workspace_env();
if roots.is_empty() {
return None;
}
if let Ok(cwd) = std::env::current_dir() {
let cwd_norm = normalize_path(&cwd);
if let Some(hit) = roots
.iter()
.find(|r| cwd_norm.starts_with(normalize_path(r)))
{
return Some(normalize_path(hit));
}
}
Some(normalize_path(&roots[0]))
}
fn parse_workspace_env() -> Vec<PathBuf> {
let Ok(ws) = std::env::var("CLAUDETTE_WORKSPACE") else {
return Vec::new();
};
#[cfg(unix)]
let sep = ':';
#[cfg(not(unix))]
let sep = ';';
ws.split(sep)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect()
}
#[must_use]
pub fn workspace_startup_diagnostics() -> Vec<String> {
WorkspaceRoots::from_env().startup_diagnostics()
}
pub(super) fn validate_read_path(input: &str) -> Result<PathBuf, String> {
let roots = WorkspaceRoots::from_env();
validate_read_path_with(input, &roots)
}
pub(super) fn validate_read_path_with(
input: &str,
roots: &WorkspaceRoots,
) -> Result<PathBuf, String> {
let resolved = resolve_input_path(input)?;
if !path_is_allowed(&resolved, roots, false) {
return Err(format!(
"path is outside $HOME ({}), the working directory (if under $HOME), \
and CLAUDETTE_WORKSPACE; reads are restricted for safety",
roots.home.display()
));
}
if let Ok(canonical) = std::fs::canonicalize(&resolved) {
if !path_is_allowed(&canonical, roots, true) {
return Err(format!(
"path resolves via symlink outside allowed roots: {} → {}",
resolved.display(),
canonical.display()
));
}
}
Ok(resolved)
}
fn path_is_allowed(path: &Path, roots: &WorkspaceRoots, canonical: bool) -> bool {
let home_canonical = if canonical {
std::fs::canonicalize(&roots.home).unwrap_or_else(|_| roots.home.clone())
} else {
roots.home.clone()
};
if path.starts_with(&home_canonical) {
return true;
}
if let Some(cwd) = &roots.cwd {
let cwd_check = if canonical {
std::fs::canonicalize(cwd).unwrap_or_else(|_| normalize_path(cwd))
} else {
normalize_path(cwd)
};
if cwd_check.starts_with(&home_canonical) && path.starts_with(&cwd_check) {
return true;
}
}
roots.workspace.iter().any(|root| {
let root_check = if canonical {
std::fs::canonicalize(root).unwrap_or_else(|_| root.clone())
} else {
normalize_path(root)
};
path.starts_with(&root_check)
})
}
pub(super) fn validate_write_path(input: &str) -> Result<PathBuf, String> {
let resolved = resolve_input_path(input)?;
let scratch = normalize_path(&files_dir());
if resolved.starts_with(&scratch) {
return Ok(resolved);
}
if let Some(mission) = crate::missions::active_mission() {
let mission_root = normalize_path(&mission.path);
if resolved.starts_with(&mission_root) {
return Ok(resolved);
}
return Err(format!(
"writes are sandboxed to {} or the active mission tree {}. \
Use a path under one of those directories.",
scratch.display(),
mission_root.display(),
));
}
Err(format!(
"writes are sandboxed to {}. Use a path under that directory.",
scratch.display()
))
}
pub(super) fn strip_html(html: &str) -> String {
let no_scripts = strip_tag_block(html, "script");
let no_styles = strip_tag_block(&no_scripts, "style");
let mut out = String::with_capacity(no_styles.len());
let mut in_tag = false;
for c in no_styles.chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => out.push(c),
_ => {}
}
}
let decoded = out
.replace(" ", " ")
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'");
let mut collapsed = String::with_capacity(decoded.len());
let mut last_ws = true;
for c in decoded.chars() {
if c.is_whitespace() {
if !last_ws {
collapsed.push(' ');
last_ws = true;
}
} else {
collapsed.push(c);
last_ws = false;
}
}
collapsed.trim().to_string()
}
fn strip_tag_block(html: &str, tag: &str) -> String {
let open_lower = format!("<{tag}");
let close_lower = format!("</{tag}>");
let lower = html.to_ascii_lowercase();
let mut out = String::with_capacity(html.len());
let mut cursor: usize = 0;
while cursor < html.len() {
let Some(rel_open) = lower[cursor..].find(&open_lower) else {
out.push_str(&html[cursor..]);
break;
};
let abs_open = cursor + rel_open;
out.push_str(&html[cursor..abs_open]);
match lower[abs_open..].find(&close_lower) {
Some(rel_close) => {
cursor = abs_open + rel_close + close_lower.len();
}
None => break,
}
}
out
}
pub(super) fn wrap_untrusted(source: &str, body: &str) -> String {
let safe_body = sanitise_untrusted(body);
let src = escape_untrusted_attr(source);
format!("<untrusted source=\"{src}\">\n{safe_body}\n</untrusted>")
}
pub(super) fn sanitise_untrusted(body: &str) -> String {
let lowered = body.to_ascii_lowercase();
let mut out = String::with_capacity(body.len() + 32);
let mut cursor = 0;
while cursor < body.len() {
let suffix = &lowered[cursor..];
if let Some(len) = match_untrusted_close_tag(suffix) {
out.push_str("</untrusted_");
cursor += len;
} else if let Some(len) = match_untrusted_entity_close_tag(suffix) {
out.push_str("</untrusted_");
cursor += len;
} else {
let ch = body[cursor..].chars().next().expect("cursor < body.len()");
out.push(ch);
cursor += ch.len_utf8();
}
}
out
}
fn match_untrusted_close_tag(lowered: &str) -> Option<usize> {
let bytes = lowered.as_bytes();
if bytes.first() != Some(&b'<') {
return None;
}
let mut i = 1;
while bytes.get(i).is_some_and(u8::is_ascii_whitespace) {
i += 1;
}
if bytes.get(i) != Some(&b'/') {
return None;
}
i += 1;
while bytes.get(i).is_some_and(u8::is_ascii_whitespace) {
i += 1;
}
if i + 9 <= bytes.len() && &bytes[i..i + 9] == b"untrusted" {
Some(i + 9)
} else {
None
}
}
fn match_untrusted_entity_close_tag(lowered: &str) -> Option<usize> {
let bytes = lowered.as_bytes();
let prefix = b"<";
if bytes.len() < prefix.len() || &bytes[..prefix.len()] != prefix {
return None;
}
let mut i = prefix.len();
while bytes.get(i).is_some_and(u8::is_ascii_whitespace) {
i += 1;
}
if bytes.get(i) != Some(&b'/') {
return None;
}
i += 1;
while bytes.get(i).is_some_and(u8::is_ascii_whitespace) {
i += 1;
}
if i + 9 <= bytes.len() && &bytes[i..i + 9] == b"untrusted" {
Some(i + 9)
} else {
None
}
}
fn escape_untrusted_attr(s: &str) -> String {
s.replace('"', """)
.replace(['\n', '\r'], " ")
.chars()
.take(200)
.collect()
}
fn external_user_agent() -> String {
format!(
"claudette/{} (claudette; https://github.com/mrdushidush/claudette)",
env!("CARGO_PKG_VERSION")
)
}
pub(super) fn external_http_client() -> Result<reqwest::blocking::Client, String> {
reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.user_agent(external_user_agent())
.build()
.map_err(|e| format!("external http: build client failed: {e}"))
}
pub(super) fn extract_str<'a>(v: &'a Value, key: &str, tool: &str) -> Result<&'a str, String> {
v.get(key)
.and_then(Value::as_str)
.ok_or_else(|| format!("{tool}: missing or non-string '{key}'"))
}
pub(super) fn parse_json_input(input: &str, tool: &str) -> Result<Value, String> {
serde_json::from_str(input).map_err(|e| format!("{tool}: invalid JSON input ({e}): {input}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_json_input_reports_tool_name() {
let err = parse_json_input("not json", "my_tool").unwrap_err();
assert!(err.contains("my_tool"));
assert!(err.contains("invalid JSON"));
}
#[test]
fn extract_str_reports_missing_field() {
let v: Value = json!({ "foo": 42 });
let err = extract_str(&v, "bar", "my_tool").unwrap_err();
assert!(err.contains("my_tool"));
assert!(err.contains("bar"));
}
#[test]
fn wrap_untrusted_encloses_body_and_source() {
let wrapped = wrap_untrusted("web_fetch:https://example.com", "hello world");
assert!(wrapped.starts_with("<untrusted source=\"web_fetch:https://example.com\">"));
assert!(wrapped.ends_with("</untrusted>"));
assert!(wrapped.contains("hello world"));
}
#[test]
fn sanitise_untrusted_defangs_close_tag_variants() {
for variant in [
"</untrusted>",
"</ untrusted>",
"< / untrusted>",
"</UNTRUSTED>",
"< /UnTrUsTeD>",
] {
let body = format!("before{variant}after");
let out = sanitise_untrusted(&body);
assert!(
!out.to_ascii_lowercase().contains("</untrusted>")
&& !out
.to_ascii_lowercase()
.replace(char::is_whitespace, "")
.contains("</untrusted>"),
"variant {variant:?} not fully defanged: {out}"
);
assert!(
out.to_ascii_lowercase().contains("</untrusted_"),
"got: {out}"
);
}
}
#[test]
fn sanitise_untrusted_defangs_entity_close_tag() {
let body = "hi </untrusted> bye";
let out = sanitise_untrusted(body);
assert!(out.contains("</untrusted_"), "got: {out}");
}
#[test]
fn wrap_untrusted_escapes_source_quotes_and_newlines() {
let wrapped = wrap_untrusted("evil\"source\nwith newlines", "body");
assert!(wrapped.contains("source=\"evil"source with newlines\""));
}
#[test]
fn normalize_path_collapses_dotdot() {
let p = normalize_path(Path::new("/a/b/../c"));
assert_eq!(p, PathBuf::from("/a/c"));
}
#[test]
fn normalize_path_collapses_dot() {
let p = normalize_path(Path::new("/a/./b/./c"));
assert_eq!(p, PathBuf::from("/a/b/c"));
}
#[test]
fn normalize_path_keeps_leading_dotdot_on_relative() {
let p = normalize_path(Path::new("../foo"));
assert_eq!(p, PathBuf::from("../foo"));
}
#[test]
fn normalize_path_empty_becomes_dot() {
let p = normalize_path(Path::new(""));
assert_eq!(p, PathBuf::from("."));
}
#[test]
fn expand_tilde_replaces_leading_tilde() {
let home = user_home();
assert_eq!(expand_tilde("~/foo/bar"), home.join("foo/bar"));
assert_eq!(expand_tilde("~"), home);
}
#[test]
fn expand_tilde_leaves_other_paths_alone() {
assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
assert_eq!(
expand_tilde("relative/path"),
PathBuf::from("relative/path")
);
assert_eq!(expand_tilde("foo/~/bar"), PathBuf::from("foo/~/bar"));
}
#[test]
fn validate_read_path_accepts_paths_under_home() {
let home = user_home();
let target = home.join("some-doc.txt");
let result = validate_read_path(target.to_str().unwrap());
assert!(result.is_ok(), "expected ok, got {result:?}");
}
#[test]
fn validate_read_path_rejects_traversal_escape() {
let bad = "~/.claudette/../../../../../../etc/passwd";
let prev = std::env::var("CLAUDETTE_WORKSPACE").ok();
std::env::remove_var("CLAUDETTE_WORKSPACE");
let result = validate_read_path(bad);
if let Some(v) = prev {
std::env::set_var("CLAUDETTE_WORKSPACE", v);
}
assert!(result.is_err(), "expected reject, got {result:?}");
assert!(
result.unwrap_err().contains("restricted for safety"),
"wrong error message"
);
}
#[test]
fn validate_read_path_respects_claudette_workspace() {
#[cfg(unix)]
let (root, target_str) = (
"/claudette-ws-test-xyz-e3a7",
"/claudette-ws-test-xyz-e3a7/hello.txt",
);
#[cfg(not(unix))]
let (root, target_str) = (
r"Z:\claudette-ws-test-xyz-e3a7",
r"Z:\claudette-ws-test-xyz-e3a7\hello.txt",
);
let prev = std::env::var("CLAUDETTE_WORKSPACE").ok();
std::env::remove_var("CLAUDETTE_WORKSPACE");
let denied = validate_read_path(target_str);
assert!(denied.is_err(), "no workspace set, expected reject");
std::env::set_var("CLAUDETTE_WORKSPACE", root);
let allowed = validate_read_path(target_str);
if let Some(v) = prev {
std::env::set_var("CLAUDETTE_WORKSPACE", v);
} else {
std::env::remove_var("CLAUDETTE_WORKSPACE");
}
assert!(
allowed.is_ok(),
"workspace set, expected ok, got {allowed:?}"
);
}
#[cfg(unix)]
#[test]
fn validate_read_path_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let link_path = user_home().join(".claudette").join("trap_symlink_test");
std::fs::create_dir_all(link_path.parent().unwrap()).expect("create .claudette dir");
let _ = std::fs::remove_file(&link_path);
symlink("/etc", &link_path).expect("create symlink");
let result = validate_read_path(link_path.to_str().unwrap());
let _ = std::fs::remove_file(&link_path);
assert!(result.is_err(), "expected reject, got {result:?}");
assert!(
result.unwrap_err().contains("via symlink"),
"wrong error: expected 'via symlink'"
);
}
#[test]
fn workspace_roots_from_env_captures_home() {
let roots = WorkspaceRoots::from_env();
assert_eq!(roots.home, normalize_path(&user_home()));
assert!(roots.cwd.is_some(), "test cwd must be readable");
}
#[test]
fn workspace_roots_parse_workspace_env_splits_on_platform_sep() {
let prev = std::env::var("CLAUDETTE_WORKSPACE").ok();
#[cfg(unix)]
let val = "/a:/b:/c";
#[cfg(not(unix))]
let val = r"C:\a;D:\b;E:\c";
std::env::set_var("CLAUDETTE_WORKSPACE", val);
let parsed = parse_workspace_env();
match prev {
Some(v) => std::env::set_var("CLAUDETTE_WORKSPACE", v),
None => std::env::remove_var("CLAUDETTE_WORKSPACE"),
}
assert_eq!(parsed.len(), 3, "expected 3 paths, got {parsed:?}");
}
#[test]
fn workspace_roots_parse_workspace_env_empty_when_unset() {
let prev = std::env::var("CLAUDETTE_WORKSPACE").ok();
std::env::remove_var("CLAUDETTE_WORKSPACE");
let parsed = parse_workspace_env();
if let Some(v) = prev {
std::env::set_var("CLAUDETTE_WORKSPACE", v);
}
assert!(parsed.is_empty(), "unset env must yield empty: {parsed:?}");
}
#[test]
fn workspace_startup_diagnostics_quiet_when_cwd_under_home() {
let roots = WorkspaceRoots {
home: PathBuf::from("/home/u"),
cwd: Some(PathBuf::from("/home/u/projects/x")),
workspace: Vec::new(),
};
assert!(roots.startup_diagnostics().is_empty());
}
#[test]
fn workspace_startup_diagnostics_warns_on_unwrappered_cwd() {
let roots = WorkspaceRoots {
home: PathBuf::from("/home/u"),
cwd: Some(PathBuf::from("/var/run/some/path")),
workspace: Vec::new(),
};
let warnings = roots.startup_diagnostics();
assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:?}");
assert!(
warnings[0].contains("CLAUDETTE_WORKSPACE"),
"warning must name the env var so users know how to fix; got {}",
warnings[0]
);
}
#[test]
fn workspace_startup_diagnostics_quiet_when_workspace_set() {
let roots = WorkspaceRoots {
home: PathBuf::from("/home/u"),
cwd: Some(PathBuf::from("/var/run/x")),
workspace: vec![PathBuf::from("/var/run/x")],
};
assert!(roots.startup_diagnostics().is_empty());
}
#[test]
fn validate_read_path_with_injects_custom_roots() {
#[cfg(unix)]
let target = "/synthetic-ws/file.txt";
#[cfg(not(unix))]
let target = r"Z:\synthetic-ws\file.txt";
let denying = WorkspaceRoots {
home: PathBuf::from(if cfg!(unix) { "/home/u" } else { r"C:\home\u" }),
cwd: None,
workspace: Vec::new(),
};
assert!(
validate_read_path_with(target, &denying).is_err(),
"no workspace, expected reject"
);
let permitting = WorkspaceRoots {
home: denying.home.clone(),
cwd: None,
workspace: vec![PathBuf::from(if cfg!(unix) {
"/synthetic-ws"
} else {
r"Z:\synthetic-ws"
})],
};
assert!(
validate_read_path_with(target, &permitting).is_ok(),
"workspace covers target, expected ok"
);
}
#[test]
fn default_workspace_root_returns_none_when_env_empty() {
let _guard = crate::test_env_lock();
let prev = std::env::var("CLAUDETTE_WORKSPACE").ok();
std::env::remove_var("CLAUDETTE_WORKSPACE");
let out = default_workspace_root();
if let Some(v) = prev {
std::env::set_var("CLAUDETTE_WORKSPACE", v);
}
assert!(
out.is_none(),
"expected None with no CLAUDETTE_WORKSPACE, got {out:?}"
);
}
#[test]
fn default_workspace_root_returns_first_root_when_cwd_outside() {
let _guard = crate::test_env_lock();
let prev = std::env::var("CLAUDETTE_WORKSPACE").ok();
#[cfg(unix)]
let synthetic = "/claudette-default-ws-2bf3";
#[cfg(not(unix))]
let synthetic = r"Z:\claudette-default-ws-2bf3";
std::env::set_var("CLAUDETTE_WORKSPACE", synthetic);
let out = default_workspace_root();
match prev {
Some(v) => std::env::set_var("CLAUDETTE_WORKSPACE", v),
None => std::env::remove_var("CLAUDETTE_WORKSPACE"),
}
let got = out.expect("default_workspace_root should return Some");
assert_eq!(got, normalize_path(&PathBuf::from(synthetic)));
}
#[test]
fn resolve_input_path_falls_back_to_workspace_when_cwd_misses() {
let _guard = crate::test_env_lock();
let prev = std::env::var("CLAUDETTE_WORKSPACE").ok();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let ws = std::env::temp_dir().join(format!("claudette-f5-ws-{nanos}"));
std::fs::create_dir_all(ws.join("crates").join("foo")).unwrap();
let target = ws.join("crates").join("foo").join("bar.rs");
std::fs::write(&target, "// f5 fixture\n").unwrap();
std::env::set_var("CLAUDETTE_WORKSPACE", &ws);
let hit = resolve_via_workspace_roots(Path::new("crates/foo/bar.rs"));
match prev {
Some(v) => std::env::set_var("CLAUDETTE_WORKSPACE", v),
None => std::env::remove_var("CLAUDETTE_WORKSPACE"),
}
let cwd_inside_ws = std::env::current_dir()
.ok()
.is_some_and(|c| normalize_path(&c).starts_with(normalize_path(&ws)));
let assert_result = if cwd_inside_ws {
None
} else {
Some(hit.clone())
};
let _ = std::fs::remove_dir_all(&ws);
if let Some(out) = assert_result {
let out = out.expect("workspace fallback should resolve relative path");
assert!(
out.ends_with(Path::new("crates").join("foo").join("bar.rs")),
"unexpected resolved path: {}",
out.display()
);
}
}
#[test]
fn validate_write_path_accepts_scratch_subdirs() {
let target = files_dir().join("draft.md");
let result = validate_write_path(target.to_str().unwrap());
assert!(result.is_ok(), "expected ok, got {result:?}");
}
#[test]
fn validate_write_path_rejects_outside_scratch() {
let outside = user_home().join("Documents").join("draft.md");
let result = validate_write_path(outside.to_str().unwrap());
assert!(result.is_err(), "expected reject, got {result:?}");
assert!(
result.unwrap_err().contains("sandboxed"),
"wrong error message"
);
}
#[test]
fn validate_write_path_rejects_dotdot_escape_from_scratch() {
let bad = "~/.claudette/files/../../etc/passwd";
let result = validate_write_path(bad);
assert!(result.is_err(), "expected reject, got {result:?}");
}
#[test]
fn get_capabilities_reports_real_config() {
let raw = dispatch_tool("get_capabilities", "{}").expect("get_capabilities");
let v: Value = serde_json::from_str(&raw).unwrap();
assert_eq!(v["name"], "Claudette");
let core = v["tools"]["core"].as_array().expect("core tools array");
assert!(
core.iter().any(|n| n == "enable_tools"),
"enable_tools meta-tool must be in core"
);
assert!(
core.iter().any(|n| n == "get_current_time"),
"get_current_time must be in core"
);
for moved in &[
"get_capabilities",
"read_file",
"todo_add",
"generate_code",
"web_search",
] {
assert!(
!core.iter().any(|n| n == moved),
"{moved} should now live in a group, not core"
);
}
let groups = v["tools"]["optional_groups"]
.as_array()
.expect("optional_groups array");
let group_names: Vec<&str> = groups
.iter()
.filter_map(|g| g.get("name").and_then(Value::as_str))
.collect();
for required in &[
"notes", "todos", "files", "code", "meta", "git", "ide", "search", "advanced",
] {
assert!(
group_names.contains(required),
"optional groups missing {required}: got {group_names:?}"
);
}
let total = v["tools"]["total"].as_u64().unwrap() as usize;
let group_sum: usize = groups
.iter()
.map(|g| g["tools"].as_array().map_or(0, Vec::len))
.sum();
assert_eq!(total, core.len() + group_sum);
assert_eq!(
v["context_window"]["num_ctx_tokens"].as_u64().unwrap(),
u64::from(crate::api::current_num_ctx())
);
let write_path = v["sandbox"]["write"].as_str().unwrap();
assert!(write_path.contains(".claudette"));
assert!(write_path.ends_with("files"));
}
#[test]
fn list_dir_classifies_file_and_subdir_correctly() {
let tmp = user_home().join(format!(
"claudette-test-list-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).expect("create tmp");
fs::create_dir_all(tmp.join("subdir")).expect("create subdir");
fs::write(tmp.join("hello.txt"), "hi").expect("write file");
let input = json!({ "path": tmp.to_str().unwrap() }).to_string();
let out = dispatch_tool("list_dir", &input).expect("list_dir should succeed");
let parsed: Value = serde_json::from_str(&out).unwrap();
let entries = parsed["entries"].as_array().expect("entries array");
let file_entry = entries
.iter()
.find(|e| e["name"] == "hello.txt")
.expect("hello.txt should be listed");
assert_eq!(file_entry["type"], "file", "hello.txt should be a file");
assert_eq!(file_entry["size"], 2);
let dir_entry = entries
.iter()
.find(|e| e["name"] == "subdir")
.expect("subdir should be listed");
assert_eq!(dir_entry["type"], "dir", "subdir should be a dir");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn list_dir_returns_known_entries() {
let _ = ensure_dir(¬es::notes_dir());
let _ = ensure_dir(&files_dir());
let input = json!({ "path": claudette_home().to_str().unwrap() }).to_string();
let out = dispatch_tool("list_dir", &input).expect("list_dir should succeed");
assert!(out.contains("\"name\":\"files\""));
assert!(out.contains("\"name\":\"notes\""));
}
fn temp_seed_dir(label: &str, seed: &[(&str, &str)]) -> PathBuf {
let dir = files_dir().join(format!(
"test-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create tmp");
for (rel, content) in seed {
let p = dir.join(rel);
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).expect("create parent");
}
fs::write(&p, content).expect("write seed file");
}
dir
}
#[test]
fn glob_search_matches_files_under_home() {
let dir = temp_seed_dir(
"glob",
&[
("a.txt", "alpha"),
("nested/b.txt", "bravo"),
("nested/c.md", "charlie"),
],
);
let pattern = format!("{}/**/*.txt", dir.display());
let input = json!({ "pattern": pattern }).to_string();
let out = dispatch_tool("glob_search", &input).expect("glob_search should succeed");
let v: Value = serde_json::from_str(&out).unwrap();
let count = v["count"].as_u64().unwrap();
assert_eq!(count, 2, "expected 2 .txt matches, got {out}");
let paths = v["paths"].as_array().unwrap();
assert!(paths.iter().any(|p| p.as_str().unwrap().ends_with("a.txt")));
assert!(paths.iter().any(|p| p.as_str().unwrap().ends_with("b.txt")));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn glob_search_rejects_path_outside_home() {
let bad = if cfg!(windows) {
"C:\\Windows\\**\\*.exe"
} else {
"/etc/**/*.conf"
};
let input = json!({ "pattern": bad }).to_string();
let result = dispatch_tool("glob_search", &input);
assert!(result.is_err(), "expected reject, got {result:?}");
assert!(
result.unwrap_err().contains("outside $HOME"),
"wrong error message"
);
}
#[test]
fn glob_search_expands_tilde() {
let _ = ensure_dir(&files_dir());
let input = json!({ "pattern": "~/.claudette/*" }).to_string();
let out = dispatch_tool("glob_search", &input).expect("glob_search should succeed");
assert!(out.contains(".claudette"));
}
#[test]
fn grep_search_finds_substring_match() {
let dir = temp_seed_dir(
"grep",
&[
("notes.md", "TODO: write tests\nDONE: build tools\n"),
("other.txt", "nothing relevant here\n"),
],
);
let input = json!({
"pattern": "todo",
"path": dir.to_str().unwrap()
})
.to_string();
let out = dispatch_tool("grep_search", &input).expect("grep_search should succeed");
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["match_count"].as_u64().unwrap(), 1);
let matches = v["matches"].as_array().unwrap();
assert_eq!(matches[0]["line"].as_u64().unwrap(), 1);
assert!(matches[0]["text"].as_str().unwrap().contains("TODO"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn grep_search_rejects_empty_pattern() {
let input = json!({ "pattern": "", "path": "~" }).to_string();
let result = dispatch_tool("grep_search", &input);
assert!(result.is_err());
assert!(result.unwrap_err().contains("pattern is empty"));
}
#[test]
fn grep_search_rejects_path_outside_home() {
let bad = if cfg!(windows) { "C:\\Windows" } else { "/etc" };
let input = json!({ "pattern": "anything", "path": bad }).to_string();
let result = dispatch_tool("grep_search", &input);
assert!(result.is_err(), "expected reject, got {result:?}");
}
#[test]
fn grep_search_skips_hidden_directories() {
let dir = temp_seed_dir("grep-hidden", &[(".secret/inside.md", "FINDME")]);
let input = json!({
"pattern": "FINDME",
"path": dir.to_str().unwrap()
})
.to_string();
let out = dispatch_tool("grep_search", &input).expect("grep_search ok");
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(
v["match_count"].as_u64().unwrap(),
0,
"should skip hidden dir, got {out}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn web_fetch_rejects_non_http_scheme() {
let input = json!({ "url": "file:///etc/passwd" }).to_string();
let result = dispatch_tool("web_fetch", &input);
assert!(result.is_err());
assert!(result.unwrap_err().contains("http://"));
let input = json!({ "url": "ftp://example.com" }).to_string();
let result = dispatch_tool("web_fetch", &input);
assert!(result.is_err());
}
#[test]
fn strip_html_removes_simple_tags() {
let html = "<p>Hello <strong>world</strong></p>";
assert_eq!(strip_html(html), "Hello world");
}
#[test]
fn strip_html_decodes_common_entities() {
let html = "<p>2 < 5 && 5 > 2</p>";
assert_eq!(strip_html(html), "2 < 5 && 5 > 2");
}
#[test]
fn strip_html_collapses_whitespace() {
let html = "<div> lots\n\n\n of space </div>";
assert_eq!(strip_html(html), "lots of space");
}
#[test]
fn strip_html_drops_script_and_style_blocks() {
let html = "<html><head><style>body{color:red}</style></head>\
<body>visible<script>var x = 1;</script>also visible</body></html>";
let cleaned = strip_html(html);
assert!(cleaned.contains("visible"));
assert!(cleaned.contains("also visible"));
assert!(!cleaned.contains("color:red"), "style content leaked");
assert!(!cleaned.contains("var x"), "script content leaked");
}
#[test]
fn strip_html_handles_uppercase_script_tag() {
let html = "before<SCRIPT>BAD</SCRIPT>after";
let cleaned = strip_html(html);
assert!(!cleaned.contains("BAD"));
assert!(cleaned.contains("before"));
assert!(cleaned.contains("after"));
}
#[test]
fn get_current_time_has_new_fields() {
let out = run_get_current_time();
let v: Value = serde_json::from_str(&out).expect("valid JSON");
assert!(v["iso8601"].is_string());
assert!(v["weekday"].is_string());
assert!(v["date"].is_string());
assert!(v["time"].is_string());
assert!(v["timezone"].is_string());
assert!(v["weekday_num"].is_number(), "missing weekday_num");
let dow = v["weekday_num"].as_u64().unwrap();
assert!((1..=7).contains(&dow), "weekday_num out of range: {dow}");
assert!(v["unix_timestamp"].is_number(), "missing unix_timestamp");
assert!(v["human"].is_string(), "missing human");
let human = v["human"].as_str().unwrap();
assert!(
human.contains(" at "),
"human should contain ' at ': {human}"
);
}
#[test]
fn note_and_todo_tools_classified_into_their_groups() {
use crate::tool_groups::{group_of, ToolGroup, CORE_TOOL_NAMES};
for tool in &[
"note_create",
"note_list",
"note_read",
"note_update",
"note_delete",
] {
assert!(
!CORE_TOOL_NAMES.contains(tool),
"{tool} must NOT be in core (regression — it should live in the Notes group)"
);
assert_eq!(
group_of(tool),
Some(ToolGroup::Notes),
"{tool} must classify as Notes"
);
}
for tool in &[
"todo_add",
"todo_list",
"todo_complete",
"todo_uncomplete",
"todo_delete",
] {
assert!(
!CORE_TOOL_NAMES.contains(tool),
"{tool} must NOT be in core (regression — it should live in the Todos group)"
);
assert_eq!(
group_of(tool),
Some(ToolGroup::Todos),
"{tool} must classify as Todos"
);
}
}
#[test]
fn notes_and_todos_round_trip() {
let stamp = chrono::Local::now().timestamp_nanos_opt().unwrap_or(0);
let title = format!("__test_note_{stamp}");
let body = format!("body-{stamp}");
let create_input = json!({
"title": title,
"body": body,
"tags": "test,polish"
})
.to_string();
let create_out = dispatch_tool("note_create", &create_input).expect("note_create");
let created: Value = serde_json::from_str(&create_out).unwrap();
let note_id = created["id"].as_str().unwrap().to_string();
let read_out =
dispatch_tool("note_read", &json!({ "id": note_id }).to_string()).expect("note_read");
let read: Value = serde_json::from_str(&read_out).unwrap();
assert_eq!(read["title"], Value::String(title.clone()));
assert!(read["body"].as_str().unwrap().contains(&body));
assert_eq!(read["tags"], json!(["test", "polish"]));
let list_out =
dispatch_tool("note_list", &json!({ "search": title }).to_string()).expect("note_list");
let list: Value = serde_json::from_str(&list_out).unwrap();
assert!(list["count"].as_u64().unwrap() >= 1);
let del_out = dispatch_tool("note_delete", &json!({ "id": note_id }).to_string())
.expect("note_delete");
assert!(del_out.contains("\"deleted\":true"));
let todo_text = format!("__test_todo_{stamp}");
let add_out =
dispatch_tool("todo_add", &json!({ "text": todo_text }).to_string()).expect("todo_add");
let added: Value = serde_json::from_str(&add_out).unwrap();
let todo_id = added["id"].as_str().unwrap().to_string();
let comp_out = dispatch_tool("todo_complete", &json!({ "id": todo_id }).to_string())
.expect("todo_complete");
assert!(comp_out.contains("\"done\":true"));
let uncomp_out = dispatch_tool("todo_uncomplete", &json!({ "id": todo_id }).to_string())
.expect("todo_uncomplete");
assert!(uncomp_out.contains("\"done\":false"));
let list_out = dispatch_tool("todo_list", r#"{"pending_only":true}"#).expect("todo_list");
assert!(list_out.contains(&todo_id));
let del_out = dispatch_tool("todo_delete", &json!({ "id": todo_id }).to_string())
.expect("todo_delete");
assert!(del_out.contains("\"deleted\":true"));
let err = dispatch_tool("todo_delete", &json!({ "id": todo_id }).to_string()).unwrap_err();
assert!(err.contains("no todo with id"), "got: {err}");
}
}