1use std::path::PathBuf;
17
18use serde::Deserialize;
19
20use super::sensitivity::SensitivityLevel;
21use super::tool_profiles::ToolProfile;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Persona {
26 pub name: String,
27 pub description: String,
28 pub tool_profile: ToolProfile,
29 pub default_read_mode: String,
30 pub compressor: String,
31 pub chunker: String,
32 pub intent_taxonomy: Vec<String>,
33 pub sensitivity_floor: SensitivityLevel,
34}
35
36#[derive(Debug, Clone, Deserialize)]
38pub struct PersonaSpec {
39 pub name: String,
40 #[serde(default)]
41 pub description: String,
42 #[serde(default = "default_tool_profile")]
43 pub tool_profile: String,
44 #[serde(default)]
46 pub tools: Vec<String>,
47 #[serde(default = "default_read_mode")]
48 pub default_read_mode: String,
49 #[serde(default = "default_compressor")]
50 pub compressor: String,
51 #[serde(default = "default_chunker")]
52 pub chunker: String,
53 #[serde(default)]
54 pub intent_taxonomy: Vec<String>,
55 #[serde(default)]
56 pub sensitivity_floor: Option<String>,
57}
58
59fn default_tool_profile() -> String {
60 "power".to_string()
61}
62fn default_read_mode() -> String {
63 "auto".to_string()
64}
65fn default_compressor() -> String {
66 "identity".to_string()
67}
68fn default_chunker() -> String {
69 "lines".to_string()
70}
71
72fn labels(items: &[&str]) -> Vec<String> {
73 items.iter().map(|s| (*s).to_string()).collect()
74}
75
76#[derive(Debug, thiserror::Error)]
78pub enum PersonaError {
79 #[error("invalid persona spec: {0}")]
80 Validation(String),
81 #[error("failed to parse persona: {0}")]
82 Parse(#[from] toml::de::Error),
83 #[error("failed to read persona at {path}: {source}")]
84 Io {
85 path: PathBuf,
86 source: std::io::Error,
87 },
88}
89
90impl PersonaSpec {
91 pub fn from_toml(text: &str) -> Result<Self, PersonaError> {
93 let spec: Self = toml::from_str(text)?;
94 spec.validate()?;
95 Ok(spec)
96 }
97
98 fn validate(&self) -> Result<(), PersonaError> {
99 if self.name.trim().is_empty() {
100 return Err(PersonaError::Validation("name must not be empty".into()));
101 }
102 if self.tool_profile.eq_ignore_ascii_case("custom") && self.tools.is_empty() {
103 return Err(PersonaError::Validation(format!(
104 "persona '{}' uses tool_profile=custom but lists no tools",
105 self.name
106 )));
107 }
108 Ok(())
109 }
110
111 #[must_use]
113 pub fn into_persona(self) -> Persona {
114 let tool_profile = if self.tool_profile.eq_ignore_ascii_case("custom") {
115 ToolProfile::Custom(self.tools)
116 } else {
117 ToolProfile::parse(&self.tool_profile).unwrap_or(ToolProfile::Power)
118 };
119 let sensitivity_floor = self
120 .sensitivity_floor
121 .as_deref()
122 .and_then(SensitivityLevel::parse)
123 .unwrap_or_default();
124 Persona {
125 name: self.name,
126 description: self.description,
127 tool_profile,
128 default_read_mode: self.default_read_mode,
129 compressor: self.compressor,
130 chunker: self.chunker,
131 intent_taxonomy: self.intent_taxonomy,
132 sensitivity_floor,
133 }
134 }
135}
136
137pub const DEFAULT_PERSONA: &str = "coding";
139
140impl Persona {
141 #[must_use]
144 pub fn coding() -> Self {
145 Persona {
146 name: "coding".to_string(),
147 description: "Software engineering on a code repository (default).".to_string(),
148 tool_profile: ToolProfile::Power,
149 default_read_mode: "auto".to_string(),
150 compressor: "identity".to_string(),
151 chunker: "lines".to_string(),
152 intent_taxonomy: super::intent_engine::TaskType::all()
153 .iter()
154 .map(|t| t.as_str().to_string())
155 .collect(),
156 sensitivity_floor: SensitivityLevel::Public,
157 }
158 }
159
160 #[must_use]
162 pub fn builtin(name: &str) -> Option<Self> {
163 match name.to_ascii_lowercase().as_str() {
164 "coding" => Some(Self::coding()),
165 "research" => Some(Self::research()),
166 "lead-gen" | "lead_gen" | "sales" => Some(Self::lead_gen()),
167 "support" => Some(Self::support()),
168 "data-analysis" | "data_analysis" => Some(Self::data_analysis()),
169 _ => None,
170 }
171 }
172
173 #[must_use]
175 pub fn builtin_names() -> Vec<String> {
176 vec![
177 "coding".to_string(),
178 "data-analysis".to_string(),
179 "lead-gen".to_string(),
180 "research".to_string(),
181 "support".to_string(),
182 ]
183 }
184
185 #[must_use]
187 pub fn research() -> Self {
188 Persona {
189 name: "research".to_string(),
190 description: "Web/document research with cited synthesis.".to_string(),
191 tool_profile: ToolProfile::Standard,
192 default_read_mode: "map".to_string(),
193 compressor: "markdown".to_string(),
194 chunker: "paragraph".to_string(),
195 intent_taxonomy: labels(&["explore", "summarize", "compare", "cite", "synthesize"]),
196 sensitivity_floor: SensitivityLevel::Public,
197 }
198 }
199
200 #[must_use]
202 pub fn lead_gen() -> Self {
203 Persona {
204 name: "lead-gen".to_string(),
205 description: "Outbound sales lead research + enrichment.".to_string(),
206 tool_profile: ToolProfile::Custom(labels(&[
207 "ctx_read",
208 "ctx_search",
209 "ctx_url_read",
210 "ctx_knowledge",
211 "ctx_semantic_search",
212 "ctx_session",
213 ])),
214 default_read_mode: "map".to_string(),
215 compressor: "prose".to_string(),
216 chunker: "paragraph".to_string(),
217 intent_taxonomy: labels(&["prospect", "qualify", "enrich", "outreach"]),
218 sensitivity_floor: SensitivityLevel::Confidential,
219 }
220 }
221
222 #[must_use]
224 pub fn support() -> Self {
225 Persona {
226 name: "support".to_string(),
227 description: "Customer-support triage, diagnosis, resolution.".to_string(),
228 tool_profile: ToolProfile::Standard,
229 default_read_mode: "auto".to_string(),
230 compressor: "prose".to_string(),
231 chunker: "paragraph".to_string(),
232 intent_taxonomy: labels(&["triage", "diagnose", "resolve", "escalate", "document"]),
233 sensitivity_floor: SensitivityLevel::Internal,
234 }
235 }
236
237 #[must_use]
239 pub fn data_analysis() -> Self {
240 Persona {
241 name: "data-analysis".to_string(),
242 description: "Structured-data ingestion, analysis, reporting.".to_string(),
243 tool_profile: ToolProfile::Standard,
244 default_read_mode: "map".to_string(),
245 compressor: "identity".to_string(),
246 chunker: "lines".to_string(),
247 intent_taxonomy: labels(&["ingest", "clean", "analyze", "visualize", "report"]),
248 sensitivity_floor: SensitivityLevel::Internal,
249 }
250 }
251
252 #[must_use]
259 pub fn resolve(cfg: &super::config::Config) -> Self {
260 let name = std::env::var("LEAN_CTX_PERSONA")
261 .ok()
262 .map(|s| s.trim().to_string())
263 .filter(|s| !s.is_empty())
264 .or_else(|| cfg.persona.clone())
265 .unwrap_or_else(|| DEFAULT_PERSONA.to_string());
266
267 if let Some(p) = Self::builtin(&name) {
268 return p;
269 }
270 match load_from_dir(&name) {
271 Ok(Some(p)) => p,
272 Ok(None) => {
273 tracing::warn!("persona '{name}' not found; falling back to coding");
274 Self::coding()
275 }
276 Err(e) => {
277 tracing::warn!("failed to load persona '{name}': {e}; falling back to coding");
278 Self::coding()
279 }
280 }
281 }
282
283 #[must_use]
286 pub fn effective_tool_profile(&self, cfg: &super::config::Config) -> ToolProfile {
287 if tool_profile_is_explicit(cfg) {
288 ToolProfile::from_config(cfg)
289 } else {
290 self.tool_profile.clone()
291 }
292 }
293}
294
295fn tool_profile_is_explicit(cfg: &super::config::Config) -> bool {
298 std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
299 || cfg.tool_profile.is_some()
300 || !cfg.tools_enabled.is_empty()
301}
302
303#[must_use]
306pub fn personas_dir() -> PathBuf {
307 if let Some(dir) = std::env::var_os("LEAN_CTX_PERSONAS_DIR") {
308 if !dir.is_empty() {
309 return PathBuf::from(dir);
310 }
311 }
312 if let Some(config_dir) = dirs::config_dir() {
313 config_dir.join("lean-ctx").join("personas")
314 } else {
315 PathBuf::from("~/.config/lean-ctx/personas")
316 }
317}
318
319fn load_from_dir(name: &str) -> Result<Option<Persona>, PersonaError> {
321 let path = personas_dir().join(format!("{name}.toml"));
322 if !path.is_file() {
323 return Ok(None);
324 }
325 let text = std::fs::read_to_string(&path).map_err(|source| PersonaError::Io {
326 path: path.clone(),
327 source,
328 })?;
329 Ok(Some(PersonaSpec::from_toml(&text)?.into_persona()))
330}
331
332#[must_use]
334pub fn list_personas() -> Vec<String> {
335 let mut names = Persona::builtin_names();
336 if let Ok(entries) = std::fs::read_dir(personas_dir()) {
337 for entry in entries.flatten() {
338 let path = entry.path();
339 if path.extension().and_then(|e| e.to_str()) == Some("toml") {
340 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
341 if !names.iter().any(|n| n == stem) {
342 names.push(stem.to_string());
343 }
344 }
345 }
346 }
347 }
348 names.sort();
349 names
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn coding_preset_matches_today_defaults() {
358 let p = Persona::coding();
359 assert_eq!(p.name, "coding");
360 assert_eq!(p.tool_profile, ToolProfile::Power);
361 assert_eq!(p.default_read_mode, "auto");
362 assert_eq!(p.sensitivity_floor, SensitivityLevel::Public);
363 assert!(p.intent_taxonomy.contains(&"generate".to_string()));
364 }
365
366 #[test]
367 fn spec_parses_and_resolves_custom_tool_surface() {
368 let spec = PersonaSpec::from_toml(
369 r#"
370name = "lead-gen"
371description = "Sales lead research"
372tool_profile = "custom"
373tools = ["ctx_read", "ctx_search", "ctx_url_read"]
374default_read_mode = "map"
375compressor = "whitespace"
376chunker = "paragraph"
377sensitivity_floor = "confidential"
378intent_taxonomy = ["prospect", "qualify", "enrich"]
379"#,
380 )
381 .unwrap();
382 let persona = spec.into_persona();
383 assert_eq!(
384 persona.tool_profile,
385 ToolProfile::Custom(vec![
386 "ctx_read".into(),
387 "ctx_search".into(),
388 "ctx_url_read".into(),
389 ])
390 );
391 assert_eq!(persona.default_read_mode, "map");
392 assert_eq!(persona.compressor, "whitespace");
393 assert_eq!(persona.sensitivity_floor, SensitivityLevel::Confidential);
394 assert!(persona.tool_profile.is_tool_enabled("ctx_url_read"));
396 assert!(!persona.tool_profile.is_tool_enabled("ctx_refactor"));
397 }
398
399 #[test]
400 fn builtin_presets_are_shipped_and_resolvable() {
401 let names = Persona::builtin_names();
402 for expected in ["coding", "research", "lead-gen", "support", "data-analysis"] {
403 assert!(
404 names.contains(&expected.to_string()),
405 "missing preset {expected}"
406 );
407 assert!(
408 Persona::builtin(expected).is_some(),
409 "unresolvable preset {expected}"
410 );
411 }
412 assert_eq!(Persona::builtin("sales").unwrap().name, "lead-gen");
414 }
415
416 #[test]
417 fn intent_taxonomy_varies_by_persona() {
418 let coding = Persona::coding().intent_taxonomy;
419 let research = Persona::research().intent_taxonomy;
420 let lead = Persona::lead_gen().intent_taxonomy;
421 assert_ne!(coding, research);
422 assert_ne!(coding, lead);
423 assert!(research.contains(&"synthesize".to_string()));
424 assert!(lead.contains(&"prospect".to_string()));
425 }
426
427 #[test]
428 fn presets_change_tool_surface() {
429 let lead = Persona::lead_gen();
431 assert!(lead.tool_profile.is_tool_enabled("ctx_url_read"));
432 assert!(!lead.tool_profile.is_tool_enabled("ctx_refactor"));
433 }
434
435 #[test]
436 fn custom_profile_without_tools_is_rejected() {
437 let err = PersonaSpec::from_toml("name = \"x\"\ntool_profile = \"custom\"\n").unwrap_err();
438 assert!(matches!(err, PersonaError::Validation(_)));
439 }
440
441 #[test]
442 fn loader_reads_persona_file_and_selection_picks_it() {
443 let dir = tempfile::tempdir().unwrap();
444 std::fs::write(
445 dir.path().join("research.toml"),
446 "name = \"research\"\ntool_profile = \"standard\"\ndefault_read_mode = \"map\"\n",
447 )
448 .unwrap();
449 std::env::set_var("LEAN_CTX_PERSONAS_DIR", dir.path());
450
451 let loaded = load_from_dir("research").unwrap().unwrap();
452 assert_eq!(loaded.name, "research");
453 assert_eq!(loaded.tool_profile, ToolProfile::Standard);
454
455 let names = list_personas();
456 assert!(names.contains(&"research".to_string()));
457 assert!(names.contains(&"coding".to_string()));
458
459 std::env::remove_var("LEAN_CTX_PERSONAS_DIR");
460 }
461}