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 && !dir.is_empty()
309 {
310 return PathBuf::from(dir);
311 }
312 crate::core::paths::config_dir_member("personas")
315 .unwrap_or_else(|_| PathBuf::from("~/.config/lean-ctx/personas"))
316}
317
318fn load_from_dir(name: &str) -> Result<Option<Persona>, PersonaError> {
320 let path = personas_dir().join(format!("{name}.toml"));
321 if !path.is_file() {
322 return Ok(None);
323 }
324 let text = std::fs::read_to_string(&path).map_err(|source| PersonaError::Io {
325 path: path.clone(),
326 source,
327 })?;
328 Ok(Some(PersonaSpec::from_toml(&text)?.into_persona()))
329}
330
331#[must_use]
333pub fn list_personas() -> Vec<String> {
334 let mut names = Persona::builtin_names();
335 if let Ok(entries) = std::fs::read_dir(personas_dir()) {
336 for entry in entries.flatten() {
337 let path = entry.path();
338 if path.extension().and_then(|e| e.to_str()) == Some("toml")
339 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
340 && !names.iter().any(|n| n == stem)
341 {
342 names.push(stem.to_string());
343 }
344 }
345 }
346 names.sort();
347 names
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn coding_preset_matches_today_defaults() {
356 let p = Persona::coding();
357 assert_eq!(p.name, "coding");
358 assert_eq!(p.tool_profile, ToolProfile::Power);
359 assert_eq!(p.default_read_mode, "auto");
360 assert_eq!(p.sensitivity_floor, SensitivityLevel::Public);
361 assert!(p.intent_taxonomy.contains(&"generate".to_string()));
362 }
363
364 #[test]
365 fn spec_parses_and_resolves_custom_tool_surface() {
366 let spec = PersonaSpec::from_toml(
367 r#"
368name = "lead-gen"
369description = "Sales lead research"
370tool_profile = "custom"
371tools = ["ctx_read", "ctx_search", "ctx_url_read"]
372default_read_mode = "map"
373compressor = "whitespace"
374chunker = "paragraph"
375sensitivity_floor = "confidential"
376intent_taxonomy = ["prospect", "qualify", "enrich"]
377"#,
378 )
379 .unwrap();
380 let persona = spec.into_persona();
381 assert_eq!(
382 persona.tool_profile,
383 ToolProfile::Custom(vec![
384 "ctx_read".into(),
385 "ctx_search".into(),
386 "ctx_url_read".into(),
387 ])
388 );
389 assert_eq!(persona.default_read_mode, "map");
390 assert_eq!(persona.compressor, "whitespace");
391 assert_eq!(persona.sensitivity_floor, SensitivityLevel::Confidential);
392 assert!(persona.tool_profile.is_tool_enabled("ctx_url_read"));
394 assert!(!persona.tool_profile.is_tool_enabled("ctx_refactor"));
395 }
396
397 #[test]
398 fn builtin_presets_are_shipped_and_resolvable() {
399 let names = Persona::builtin_names();
400 for expected in ["coding", "research", "lead-gen", "support", "data-analysis"] {
401 assert!(
402 names.contains(&expected.to_string()),
403 "missing preset {expected}"
404 );
405 assert!(
406 Persona::builtin(expected).is_some(),
407 "unresolvable preset {expected}"
408 );
409 }
410 assert_eq!(Persona::builtin("sales").unwrap().name, "lead-gen");
412 }
413
414 #[test]
415 fn intent_taxonomy_varies_by_persona() {
416 let coding = Persona::coding().intent_taxonomy;
417 let research = Persona::research().intent_taxonomy;
418 let lead = Persona::lead_gen().intent_taxonomy;
419 assert_ne!(coding, research);
420 assert_ne!(coding, lead);
421 assert!(research.contains(&"synthesize".to_string()));
422 assert!(lead.contains(&"prospect".to_string()));
423 }
424
425 #[test]
426 fn presets_change_tool_surface() {
427 let lead = Persona::lead_gen();
429 assert!(lead.tool_profile.is_tool_enabled("ctx_url_read"));
430 assert!(!lead.tool_profile.is_tool_enabled("ctx_refactor"));
431 }
432
433 #[test]
434 fn custom_profile_without_tools_is_rejected() {
435 let err = PersonaSpec::from_toml("name = \"x\"\ntool_profile = \"custom\"\n").unwrap_err();
436 assert!(matches!(err, PersonaError::Validation(_)));
437 }
438
439 #[test]
440 fn loader_reads_persona_file_and_selection_picks_it() {
441 let dir = tempfile::tempdir().unwrap();
442 std::fs::write(
443 dir.path().join("research.toml"),
444 "name = \"research\"\ntool_profile = \"standard\"\ndefault_read_mode = \"map\"\n",
445 )
446 .unwrap();
447 crate::test_env::set_var("LEAN_CTX_PERSONAS_DIR", dir.path());
448
449 let loaded = load_from_dir("research").unwrap().unwrap();
450 assert_eq!(loaded.name, "research");
451 assert_eq!(loaded.tool_profile, ToolProfile::Standard);
452
453 let names = list_personas();
454 assert!(names.contains(&"research".to_string()));
455 assert!(names.contains(&"coding".to_string()));
456
457 crate::test_env::remove_var("LEAN_CTX_PERSONAS_DIR");
458 }
459}