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
140#[must_use]
149pub fn active() -> Persona {
150 Persona::resolve(&super::config::Config::load())
151}
152
153impl Persona {
154 #[must_use]
157 pub fn coding() -> Self {
158 Persona {
159 name: "coding".to_string(),
160 description: "Software engineering on a code repository (default).".to_string(),
161 tool_profile: ToolProfile::Power,
162 default_read_mode: "auto".to_string(),
163 compressor: "identity".to_string(),
164 chunker: "lines".to_string(),
165 intent_taxonomy: super::intent_engine::TaskType::all()
166 .iter()
167 .map(|t| t.as_str().to_string())
168 .collect(),
169 sensitivity_floor: SensitivityLevel::Public,
170 }
171 }
172
173 #[must_use]
175 pub fn builtin(name: &str) -> Option<Self> {
176 match name.to_ascii_lowercase().as_str() {
177 "coding" => Some(Self::coding()),
178 "research" => Some(Self::research()),
179 "lead-gen" | "lead_gen" | "sales" => Some(Self::lead_gen()),
180 "support" => Some(Self::support()),
181 "data-analysis" | "data_analysis" => Some(Self::data_analysis()),
182 _ => None,
183 }
184 }
185
186 #[must_use]
188 pub fn builtin_names() -> Vec<String> {
189 vec![
190 "coding".to_string(),
191 "data-analysis".to_string(),
192 "lead-gen".to_string(),
193 "research".to_string(),
194 "support".to_string(),
195 ]
196 }
197
198 #[must_use]
200 pub fn research() -> Self {
201 Persona {
202 name: "research".to_string(),
203 description: "Web/document research with cited synthesis.".to_string(),
204 tool_profile: ToolProfile::Standard,
205 default_read_mode: "map".to_string(),
206 compressor: "markdown".to_string(),
207 chunker: "paragraph".to_string(),
208 intent_taxonomy: labels(&["explore", "summarize", "compare", "cite", "synthesize"]),
209 sensitivity_floor: SensitivityLevel::Public,
210 }
211 }
212
213 #[must_use]
215 pub fn lead_gen() -> Self {
216 Persona {
217 name: "lead-gen".to_string(),
218 description: "Outbound sales lead research + enrichment.".to_string(),
219 tool_profile: ToolProfile::Custom(labels(&[
220 "ctx_read",
221 "ctx_search",
222 "ctx_url_read",
223 "ctx_knowledge",
224 "ctx_session",
225 ])),
226 default_read_mode: "map".to_string(),
227 compressor: "prose".to_string(),
228 chunker: "paragraph".to_string(),
229 intent_taxonomy: labels(&["prospect", "qualify", "enrich", "outreach"]),
230 sensitivity_floor: SensitivityLevel::Confidential,
231 }
232 }
233
234 #[must_use]
236 pub fn support() -> Self {
237 Persona {
238 name: "support".to_string(),
239 description: "Customer-support triage, diagnosis, resolution.".to_string(),
240 tool_profile: ToolProfile::Standard,
241 default_read_mode: "auto".to_string(),
242 compressor: "prose".to_string(),
243 chunker: "paragraph".to_string(),
244 intent_taxonomy: labels(&["triage", "diagnose", "resolve", "escalate", "document"]),
245 sensitivity_floor: SensitivityLevel::Internal,
246 }
247 }
248
249 #[must_use]
251 pub fn data_analysis() -> Self {
252 Persona {
253 name: "data-analysis".to_string(),
254 description: "Structured-data ingestion, analysis, reporting.".to_string(),
255 tool_profile: ToolProfile::Standard,
256 default_read_mode: "map".to_string(),
257 compressor: "identity".to_string(),
258 chunker: "lines".to_string(),
259 intent_taxonomy: labels(&["ingest", "clean", "analyze", "visualize", "report"]),
260 sensitivity_floor: SensitivityLevel::Internal,
261 }
262 }
263
264 #[must_use]
271 pub fn resolve(cfg: &super::config::Config) -> Self {
272 let name = std::env::var("LEAN_CTX_PERSONA")
273 .ok()
274 .map(|s| s.trim().to_string())
275 .filter(|s| !s.is_empty())
276 .or_else(|| cfg.persona.clone())
277 .unwrap_or_else(|| DEFAULT_PERSONA.to_string());
278
279 if let Some(p) = Self::builtin(&name) {
280 return p;
281 }
282 match load_from_dir(&name) {
283 Ok(Some(p)) => p,
284 Ok(None) => {
285 tracing::warn!("persona '{name}' not found; falling back to coding");
286 Self::coding()
287 }
288 Err(e) => {
289 tracing::warn!("failed to load persona '{name}': {e}; falling back to coding");
290 Self::coding()
291 }
292 }
293 }
294
295 #[must_use]
298 pub fn effective_tool_profile(&self, cfg: &super::config::Config) -> ToolProfile {
299 if tool_profile_is_explicit(cfg) {
300 ToolProfile::from_config(cfg)
301 } else {
302 self.tool_profile.clone()
303 }
304 }
305
306 #[must_use]
311 pub fn read_mode_override(&self) -> Option<String> {
312 let mode = self.default_read_mode.trim();
313 if mode.is_empty() || mode.eq_ignore_ascii_case("auto") {
314 None
315 } else {
316 Some(mode.to_string())
317 }
318 }
319
320 #[must_use]
326 pub fn prompt_block(&self) -> String {
327 if self.name == DEFAULT_PERSONA {
328 return String::new();
329 }
330 let mut out = format!("PERSONA: {}", self.name);
331 let desc = self.description.trim();
332 if !desc.is_empty() {
333 out.push_str(&format!(" — {desc}"));
334 }
335 if !self.intent_taxonomy.is_empty() {
336 out.push_str(&format!("\nINTENTS: {}", self.intent_taxonomy.join(", ")));
337 }
338 out.push_str(&format!(
339 "\nDEFAULTS: read mode {}; sensitivity floor {}",
340 self.default_read_mode,
341 self.sensitivity_floor.as_str()
342 ));
343 out.push('\n');
344 out
345 }
346}
347
348fn tool_profile_is_explicit(cfg: &super::config::Config) -> bool {
351 std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
352 || cfg.tool_profile.is_some()
353 || !cfg.tools_enabled.is_empty()
354}
355
356#[must_use]
359pub fn personas_dir() -> PathBuf {
360 if let Some(dir) = std::env::var_os("LEAN_CTX_PERSONAS_DIR")
361 && !dir.is_empty()
362 {
363 return PathBuf::from(dir);
364 }
365 crate::core::paths::config_dir_member("personas")
368 .unwrap_or_else(|_| PathBuf::from("~/.config/lean-ctx/personas"))
369}
370
371pub fn load_from_dir(name: &str) -> Result<Option<Persona>, PersonaError> {
373 let path = personas_dir().join(format!("{name}.toml"));
374 if !path.is_file() {
375 return Ok(None);
376 }
377 let text = std::fs::read_to_string(&path).map_err(|source| PersonaError::Io {
378 path: path.clone(),
379 source,
380 })?;
381 Ok(Some(PersonaSpec::from_toml(&text)?.into_persona()))
382}
383
384#[must_use]
386pub fn list_personas() -> Vec<String> {
387 let mut names = Persona::builtin_names();
388 if let Ok(entries) = std::fs::read_dir(personas_dir()) {
389 for entry in entries.flatten() {
390 let path = entry.path();
391 if path.extension().and_then(|e| e.to_str()) == Some("toml")
392 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
393 && !names.iter().any(|n| n == stem)
394 {
395 names.push(stem.to_string());
396 }
397 }
398 }
399 names.sort();
400 names
401}
402
403#[cfg(test)]
404pub mod tests {
405 use super::*;
406
407 #[test]
408 fn coding_preset_matches_today_defaults() {
409 let p = Persona::coding();
410 assert_eq!(p.name, "coding");
411 assert_eq!(p.tool_profile, ToolProfile::Power);
412 assert_eq!(p.default_read_mode, "auto");
413 assert_eq!(p.sensitivity_floor, SensitivityLevel::Public);
414 assert!(p.intent_taxonomy.contains(&"generate".to_string()));
415 }
416
417 #[test]
418 fn spec_parses_and_resolves_custom_tool_surface() {
419 let spec = PersonaSpec::from_toml(
420 r#"
421name = "lead-gen"
422description = "Sales lead research"
423tool_profile = "custom"
424tools = ["ctx_read", "ctx_search", "ctx_url_read"]
425default_read_mode = "map"
426compressor = "whitespace"
427chunker = "paragraph"
428sensitivity_floor = "confidential"
429intent_taxonomy = ["prospect", "qualify", "enrich"]
430"#,
431 )
432 .unwrap();
433 let persona = spec.into_persona();
434 assert_eq!(
435 persona.tool_profile,
436 ToolProfile::Custom(vec![
437 "ctx_read".into(),
438 "ctx_search".into(),
439 "ctx_url_read".into(),
440 ])
441 );
442 assert_eq!(persona.default_read_mode, "map");
443 assert_eq!(persona.compressor, "whitespace");
444 assert_eq!(persona.sensitivity_floor, SensitivityLevel::Confidential);
445 assert!(persona.tool_profile.is_tool_enabled("ctx_url_read"));
447 assert!(!persona.tool_profile.is_tool_enabled("ctx_refactor"));
448 }
449
450 #[test]
451 fn builtin_presets_are_shipped_and_resolvable() {
452 let names = Persona::builtin_names();
453 for expected in ["coding", "research", "lead-gen", "support", "data-analysis"] {
454 assert!(
455 names.contains(&expected.to_string()),
456 "missing preset {expected}"
457 );
458 assert!(
459 Persona::builtin(expected).is_some(),
460 "unresolvable preset {expected}"
461 );
462 }
463 assert_eq!(Persona::builtin("sales").unwrap().name, "lead-gen");
465 }
466
467 #[test]
468 fn intent_taxonomy_varies_by_persona() {
469 let coding = Persona::coding().intent_taxonomy;
470 let research = Persona::research().intent_taxonomy;
471 let lead = Persona::lead_gen().intent_taxonomy;
472 assert_ne!(coding, research);
473 assert_ne!(coding, lead);
474 assert!(research.contains(&"synthesize".to_string()));
475 assert!(lead.contains(&"prospect".to_string()));
476 }
477
478 #[test]
479 fn presets_change_tool_surface() {
480 let lead = Persona::lead_gen();
482 assert!(lead.tool_profile.is_tool_enabled("ctx_url_read"));
483 assert!(!lead.tool_profile.is_tool_enabled("ctx_refactor"));
484 }
485
486 #[test]
487 fn custom_profile_without_tools_is_rejected() {
488 let err = PersonaSpec::from_toml("name = \"x\"\ntool_profile = \"custom\"\n").unwrap_err();
489 assert!(matches!(err, PersonaError::Validation(_)));
490 }
491
492 #[test]
493 fn read_mode_override_treats_auto_as_no_opinion() {
494 assert_eq!(Persona::coding().read_mode_override(), None);
496 assert_eq!(Persona::support().read_mode_override(), None);
497 assert_eq!(
499 Persona::research().read_mode_override(),
500 Some("map".to_string())
501 );
502 assert_eq!(
503 Persona::lead_gen().read_mode_override(),
504 Some("map".to_string())
505 );
506 let mut p = Persona::coding();
508 p.default_read_mode = " ".to_string();
509 assert_eq!(p.read_mode_override(), None);
510 }
511
512 #[test]
513 fn prompt_block_is_empty_for_coding_and_carries_domain_vocabulary() {
514 assert_eq!(Persona::coding().prompt_block(), "");
516
517 let block = Persona::research().prompt_block();
518 assert!(block.contains("PERSONA: research"), "{block}");
519 assert!(
520 block.contains("INTENTS: explore, summarize, compare, cite, synthesize"),
521 "{block}"
522 );
523 assert!(block.contains("read mode map"), "{block}");
524
525 let lead = Persona::lead_gen().prompt_block();
526 assert!(lead.contains("sensitivity floor confidential"), "{lead}");
527 }
528
529 #[test]
530 fn active_honours_env_selection() {
531 let _guard = crate::core::data_dir::test_env_lock();
532 crate::test_env::set_var("LEAN_CTX_PERSONA", "research");
533 let p = active();
534 crate::test_env::remove_var("LEAN_CTX_PERSONA");
535 assert_eq!(p.name, "research");
536 }
537
538 #[test]
539 fn loader_reads_persona_file_and_selection_picks_it() {
540 let _env_lock = crate::core::data_dir::test_env_lock();
541 let dir = tempfile::tempdir().unwrap();
542 std::fs::write(
543 dir.path().join("research.toml"),
544 "name = \"research\"\ntool_profile = \"standard\"\ndefault_read_mode = \"map\"\n",
545 )
546 .unwrap();
547 crate::test_env::set_var("LEAN_CTX_PERSONAS_DIR", dir.path());
548
549 let loaded = load_from_dir("research").unwrap().unwrap();
550 assert_eq!(loaded.name, "research");
551 assert_eq!(loaded.tool_profile, ToolProfile::Standard);
552
553 let names = list_personas();
554 assert!(names.contains(&"research".to_string()));
555 assert!(names.contains(&"coding".to_string()));
556
557 crate::test_env::remove_var("LEAN_CTX_PERSONAS_DIR");
558 }
559}