Skip to main content

chub_core/team/
profiles.rs

1use std::fs;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{Error, Result};
7use crate::team::project::project_chub_dir;
8
9/// A context profile definition.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Profile {
12    pub name: String,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub extends: Option<String>,
15    #[serde(default)]
16    pub description: Option<String>,
17    #[serde(default)]
18    pub pins: Vec<String>,
19    #[serde(default)]
20    pub context: Vec<String>,
21    #[serde(default)]
22    pub rules: Vec<String>,
23}
24
25/// A resolved profile with inheritance applied.
26#[derive(Debug, Clone)]
27pub struct ResolvedProfile {
28    pub name: String,
29    pub description: Option<String>,
30    pub pins: Vec<String>,
31    pub context: Vec<String>,
32    pub rules: Vec<String>,
33}
34
35fn profiles_dir() -> Option<PathBuf> {
36    project_chub_dir().map(|d| d.join("profiles"))
37}
38
39/// Load a raw profile by name.
40pub fn load_profile(name: &str) -> Result<Profile> {
41    let dir = profiles_dir().ok_or_else(|| {
42        Error::Config("No .chub/ directory found. Run `chub init` first.".to_string())
43    })?;
44
45    let path = dir.join(format!("{}.yaml", name));
46    if !path.exists() {
47        let alt = dir.join(format!("{}.yml", name));
48        if alt.exists() {
49            let raw = fs::read_to_string(&alt)?;
50            return serde_yaml::from_str(&raw).map_err(|e| Error::Config(e.to_string()));
51        }
52        return Err(Error::Config(format!("Profile \"{}\" not found.", name)));
53    }
54
55    let raw = fs::read_to_string(&path)?;
56    serde_yaml::from_str(&raw).map_err(|e| Error::Config(e.to_string()))
57}
58
59/// Resolve a profile with inheritance (max depth 10 to prevent cycles).
60pub fn resolve_profile(name: &str) -> Result<ResolvedProfile> {
61    let mut chain = Vec::new();
62    let mut current = name.to_string();
63    let max_depth = 10;
64
65    for _ in 0..max_depth {
66        if chain.contains(&current) {
67            return Err(Error::Config(format!(
68                "Circular profile inheritance detected: {}",
69                chain.join(" → ")
70            )));
71        }
72        let profile = load_profile(&current)?;
73        chain.push(current.clone());
74        if let Some(ref parent) = profile.extends {
75            current = parent.clone();
76        } else {
77            break;
78        }
79    }
80
81    // Resolve from root to leaf
82    let mut resolved = ResolvedProfile {
83        name: name.to_string(),
84        description: None,
85        pins: Vec::new(),
86        context: Vec::new(),
87        rules: Vec::new(),
88    };
89
90    // Load in reverse order (root first, leaf last)
91    for profile_name in chain.iter().rev() {
92        let profile = load_profile(profile_name)?;
93        if profile.description.is_some() {
94            resolved.description = profile.description;
95        }
96        // Extend (not replace) — child adds to parent
97        for pin in &profile.pins {
98            if !resolved.pins.contains(pin) {
99                resolved.pins.push(pin.clone());
100            }
101        }
102        for ctx in &profile.context {
103            if !resolved.context.contains(ctx) {
104                resolved.context.push(ctx.clone());
105            }
106        }
107        for rule in &profile.rules {
108            if !resolved.rules.contains(rule) {
109                resolved.rules.push(rule.clone());
110            }
111        }
112    }
113
114    Ok(resolved)
115}
116
117/// List available profile names.
118pub fn list_profiles() -> Vec<(String, Option<String>)> {
119    let dir = match profiles_dir() {
120        Some(d) if d.exists() => d,
121        _ => return vec![],
122    };
123
124    let mut profiles = Vec::new();
125    let entries = match fs::read_dir(&dir) {
126        Ok(e) => e,
127        Err(_) => return vec![],
128    };
129
130    for entry in entries.filter_map(|e| e.ok()) {
131        let path = entry.path();
132        let ext = path.extension().and_then(|e| e.to_str());
133        if ext != Some("yaml") && ext != Some("yml") {
134            continue;
135        }
136        let stem = path
137            .file_stem()
138            .unwrap_or_default()
139            .to_string_lossy()
140            .to_string();
141        let desc = fs::read_to_string(&path)
142            .ok()
143            .and_then(|s| serde_yaml::from_str::<Profile>(&s).ok())
144            .and_then(|p| p.description);
145        profiles.push((stem, desc));
146    }
147
148    profiles.sort_by(|a, b| a.0.cmp(&b.0));
149    profiles
150}
151
152/// Get the active profile name (from env var or session file).
153pub fn get_active_profile() -> Option<String> {
154    // Check env var first
155    if let Ok(profile) = std::env::var("CHUB_PROFILE") {
156        if !profile.is_empty() {
157            return Some(profile);
158        }
159    }
160
161    // Check session file
162    let session_path = project_chub_dir()?.join(".active_profile");
163    fs::read_to_string(&session_path)
164        .ok()
165        .map(|s| s.trim().to_string())
166        .filter(|s| !s.is_empty())
167}
168
169/// Set the active profile for this session.
170pub fn set_active_profile(name: Option<&str>) -> Result<()> {
171    let chub_dir = project_chub_dir().ok_or_else(|| {
172        Error::Config("No .chub/ directory found. Run `chub init` first.".to_string())
173    })?;
174
175    let session_path = chub_dir.join(".active_profile");
176
177    match name {
178        Some(n) => {
179            // Validate profile exists
180            let _ = load_profile(n)?;
181            fs::write(&session_path, n)?;
182        }
183        None => {
184            let _ = fs::remove_file(&session_path);
185        }
186    }
187
188    Ok(())
189}
190
191/// Auto-detect profile based on file path and auto_profile config.
192pub fn auto_detect_profile(file_path: &str) -> Option<String> {
193    let project_config = crate::team::project::load_project_config()?;
194    let auto_profiles = project_config.auto_profile?;
195
196    for entry in &auto_profiles {
197        let pattern = format!("**/{}", entry.path);
198        if let Ok(glob) = globset::Glob::new(&pattern) {
199            let matcher = glob.compile_matcher();
200            if matcher.is_match(file_path) {
201                return Some(entry.profile.clone());
202            }
203        }
204        // Simple prefix match fallback
205        let prefix = entry.path.trim_end_matches("**").trim_end_matches('/');
206        if file_path.starts_with(prefix) {
207            return Some(entry.profile.clone());
208        }
209    }
210
211    None
212}