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_semantic_search",
225 "ctx_session",
226 ])),
227 default_read_mode: "map".to_string(),
228 compressor: "prose".to_string(),
229 chunker: "paragraph".to_string(),
230 intent_taxonomy: labels(&["prospect", "qualify", "enrich", "outreach"]),
231 sensitivity_floor: SensitivityLevel::Confidential,
232 }
233 }
234
235 #[must_use]
237 pub fn support() -> Self {
238 Persona {
239 name: "support".to_string(),
240 description: "Customer-support triage, diagnosis, resolution.".to_string(),
241 tool_profile: ToolProfile::Standard,
242 default_read_mode: "auto".to_string(),
243 compressor: "prose".to_string(),
244 chunker: "paragraph".to_string(),
245 intent_taxonomy: labels(&["triage", "diagnose", "resolve", "escalate", "document"]),
246 sensitivity_floor: SensitivityLevel::Internal,
247 }
248 }
249
250 #[must_use]
252 pub fn data_analysis() -> Self {
253 Persona {
254 name: "data-analysis".to_string(),
255 description: "Structured-data ingestion, analysis, reporting.".to_string(),
256 tool_profile: ToolProfile::Standard,
257 default_read_mode: "map".to_string(),
258 compressor: "identity".to_string(),
259 chunker: "lines".to_string(),
260 intent_taxonomy: labels(&["ingest", "clean", "analyze", "visualize", "report"]),
261 sensitivity_floor: SensitivityLevel::Internal,
262 }
263 }
264
265 #[must_use]
272 pub fn resolve(cfg: &super::config::Config) -> Self {
273 let name = std::env::var("LEAN_CTX_PERSONA")
274 .ok()
275 .map(|s| s.trim().to_string())
276 .filter(|s| !s.is_empty())
277 .or_else(|| cfg.persona.clone())
278 .unwrap_or_else(|| DEFAULT_PERSONA.to_string());
279
280 if let Some(p) = Self::builtin(&name) {
281 return p;
282 }
283 match load_from_dir(&name) {
284 Ok(Some(p)) => p,
285 Ok(None) => {
286 tracing::warn!("persona '{name}' not found; falling back to coding");
287 Self::coding()
288 }
289 Err(e) => {
290 tracing::warn!("failed to load persona '{name}': {e}; falling back to coding");
291 Self::coding()
292 }
293 }
294 }
295
296 #[must_use]
299 pub fn effective_tool_profile(&self, cfg: &super::config::Config) -> ToolProfile {
300 if tool_profile_is_explicit(cfg) {
301 ToolProfile::from_config(cfg)
302 } else {
303 self.tool_profile.clone()
304 }
305 }
306
307 #[must_use]
312 pub fn read_mode_override(&self) -> Option<String> {
313 let mode = self.default_read_mode.trim();
314 if mode.is_empty() || mode.eq_ignore_ascii_case("auto") {
315 None
316 } else {
317 Some(mode.to_string())
318 }
319 }
320
321 #[must_use]
327 pub fn prompt_block(&self) -> String {
328 if self.name == DEFAULT_PERSONA {
329 return String::new();
330 }
331 let mut out = format!("PERSONA: {}", self.name);
332 let desc = self.description.trim();
333 if !desc.is_empty() {
334 out.push_str(&format!(" — {desc}"));
335 }
336 if !self.intent_taxonomy.is_empty() {
337 out.push_str(&format!("\nINTENTS: {}", self.intent_taxonomy.join(", ")));
338 }
339 out.push_str(&format!(
340 "\nDEFAULTS: read mode {}; sensitivity floor {}",
341 self.default_read_mode,
342 self.sensitivity_floor.as_str()
343 ));
344 out.push('\n');
345 out
346 }
347}
348
349fn tool_profile_is_explicit(cfg: &super::config::Config) -> bool {
352 std::env::var("LEAN_CTX_TOOL_PROFILE").is_ok()
353 || cfg.tool_profile.is_some()
354 || !cfg.tools_enabled.is_empty()
355}
356
357#[must_use]
360pub fn personas_dir() -> PathBuf {
361 if let Some(dir) = std::env::var_os("LEAN_CTX_PERSONAS_DIR")
362 && !dir.is_empty()
363 {
364 return PathBuf::from(dir);
365 }
366 crate::core::paths::config_dir_member("personas")
369 .unwrap_or_else(|_| PathBuf::from("~/.config/lean-ctx/personas"))
370}
371
372fn load_from_dir(name: &str) -> Result<Option<Persona>, PersonaError> {
374 let path = personas_dir().join(format!("{name}.toml"));
375 if !path.is_file() {
376 return Ok(None);
377 }
378 let text = std::fs::read_to_string(&path).map_err(|source| PersonaError::Io {
379 path: path.clone(),
380 source,
381 })?;
382 Ok(Some(PersonaSpec::from_toml(&text)?.into_persona()))
383}
384
385#[must_use]
387pub fn list_personas() -> Vec<String> {
388 let mut names = Persona::builtin_names();
389 if let Ok(entries) = std::fs::read_dir(personas_dir()) {
390 for entry in entries.flatten() {
391 let path = entry.path();
392 if path.extension().and_then(|e| e.to_str()) == Some("toml")
393 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
394 && !names.iter().any(|n| n == stem)
395 {
396 names.push(stem.to_string());
397 }
398 }
399 }
400 names.sort();
401 names
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn coding_preset_matches_today_defaults() {
410 let p = Persona::coding();
411 assert_eq!(p.name, "coding");
412 assert_eq!(p.tool_profile, ToolProfile::Power);
413 assert_eq!(p.default_read_mode, "auto");
414 assert_eq!(p.sensitivity_floor, SensitivityLevel::Public);
415 assert!(p.intent_taxonomy.contains(&"generate".to_string()));
416 }
417
418 #[test]
419 fn spec_parses_and_resolves_custom_tool_surface() {
420 let spec = PersonaSpec::from_toml(
421 r#"
422name = "lead-gen"
423description = "Sales lead research"
424tool_profile = "custom"
425tools = ["ctx_read", "ctx_search", "ctx_url_read"]
426default_read_mode = "map"
427compressor = "whitespace"
428chunker = "paragraph"
429sensitivity_floor = "confidential"
430intent_taxonomy = ["prospect", "qualify", "enrich"]
431"#,
432 )
433 .unwrap();
434 let persona = spec.into_persona();
435 assert_eq!(
436 persona.tool_profile,
437 ToolProfile::Custom(vec![
438 "ctx_read".into(),
439 "ctx_search".into(),
440 "ctx_url_read".into(),
441 ])
442 );
443 assert_eq!(persona.default_read_mode, "map");
444 assert_eq!(persona.compressor, "whitespace");
445 assert_eq!(persona.sensitivity_floor, SensitivityLevel::Confidential);
446 assert!(persona.tool_profile.is_tool_enabled("ctx_url_read"));
448 assert!(!persona.tool_profile.is_tool_enabled("ctx_refactor"));
449 }
450
451 #[test]
452 fn builtin_presets_are_shipped_and_resolvable() {
453 let names = Persona::builtin_names();
454 for expected in ["coding", "research", "lead-gen", "support", "data-analysis"] {
455 assert!(
456 names.contains(&expected.to_string()),
457 "missing preset {expected}"
458 );
459 assert!(
460 Persona::builtin(expected).is_some(),
461 "unresolvable preset {expected}"
462 );
463 }
464 assert_eq!(Persona::builtin("sales").unwrap().name, "lead-gen");
466 }
467
468 #[test]
469 fn intent_taxonomy_varies_by_persona() {
470 let coding = Persona::coding().intent_taxonomy;
471 let research = Persona::research().intent_taxonomy;
472 let lead = Persona::lead_gen().intent_taxonomy;
473 assert_ne!(coding, research);
474 assert_ne!(coding, lead);
475 assert!(research.contains(&"synthesize".to_string()));
476 assert!(lead.contains(&"prospect".to_string()));
477 }
478
479 #[test]
480 fn presets_change_tool_surface() {
481 let lead = Persona::lead_gen();
483 assert!(lead.tool_profile.is_tool_enabled("ctx_url_read"));
484 assert!(!lead.tool_profile.is_tool_enabled("ctx_refactor"));
485 }
486
487 #[test]
488 fn custom_profile_without_tools_is_rejected() {
489 let err = PersonaSpec::from_toml("name = \"x\"\ntool_profile = \"custom\"\n").unwrap_err();
490 assert!(matches!(err, PersonaError::Validation(_)));
491 }
492
493 #[test]
494 fn read_mode_override_treats_auto_as_no_opinion() {
495 assert_eq!(Persona::coding().read_mode_override(), None);
497 assert_eq!(Persona::support().read_mode_override(), None);
498 assert_eq!(
500 Persona::research().read_mode_override(),
501 Some("map".to_string())
502 );
503 assert_eq!(
504 Persona::lead_gen().read_mode_override(),
505 Some("map".to_string())
506 );
507 let mut p = Persona::coding();
509 p.default_read_mode = " ".to_string();
510 assert_eq!(p.read_mode_override(), None);
511 }
512
513 #[test]
514 fn prompt_block_is_empty_for_coding_and_carries_domain_vocabulary() {
515 assert_eq!(Persona::coding().prompt_block(), "");
517
518 let block = Persona::research().prompt_block();
519 assert!(block.contains("PERSONA: research"), "{block}");
520 assert!(
521 block.contains("INTENTS: explore, summarize, compare, cite, synthesize"),
522 "{block}"
523 );
524 assert!(block.contains("read mode map"), "{block}");
525
526 let lead = Persona::lead_gen().prompt_block();
527 assert!(lead.contains("sensitivity floor confidential"), "{lead}");
528 }
529
530 #[test]
531 fn active_honours_env_selection() {
532 let _guard = crate::core::data_dir::test_env_lock();
533 crate::test_env::set_var("LEAN_CTX_PERSONA", "research");
534 let p = active();
535 crate::test_env::remove_var("LEAN_CTX_PERSONA");
536 assert_eq!(p.name, "research");
537 }
538
539 #[test]
540 fn loader_reads_persona_file_and_selection_picks_it() {
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}