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    let mut resolved_all = false;
65
66    for _ in 0..max_depth {
67        if chain.contains(&current) {
68            return Err(Error::Config(format!(
69                "Circular profile inheritance detected: {}",
70                chain.join(" → ")
71            )));
72        }
73        let profile = load_profile(&current)?;
74        chain.push(current.clone());
75        if let Some(ref parent) = profile.extends {
76            current = parent.clone();
77        } else {
78            resolved_all = true;
79            break;
80        }
81    }
82
83    if !resolved_all {
84        return Err(Error::Config(format!(
85            "Profile inheritance chain exceeds maximum depth of {} ({})",
86            max_depth,
87            chain.join(" → ")
88        )));
89    }
90
91    // Resolve from root to leaf
92    let mut resolved = ResolvedProfile {
93        name: name.to_string(),
94        description: None,
95        pins: Vec::new(),
96        context: Vec::new(),
97        rules: Vec::new(),
98    };
99
100    // Load in reverse order (root first, leaf last)
101    for profile_name in chain.iter().rev() {
102        let profile = load_profile(profile_name)?;
103        if profile.description.is_some() {
104            resolved.description = profile.description;
105        }
106        // Extend (not replace) — child adds to parent
107        for pin in &profile.pins {
108            if !resolved.pins.contains(pin) {
109                resolved.pins.push(pin.clone());
110            }
111        }
112        for ctx in &profile.context {
113            if !resolved.context.contains(ctx) {
114                resolved.context.push(ctx.clone());
115            }
116        }
117        for rule in &profile.rules {
118            if !resolved.rules.contains(rule) {
119                resolved.rules.push(rule.clone());
120            }
121        }
122    }
123
124    Ok(resolved)
125}
126
127/// List available profile names.
128pub fn list_profiles() -> Vec<(String, Option<String>)> {
129    let dir = match profiles_dir() {
130        Some(d) if d.exists() => d,
131        _ => return vec![],
132    };
133
134    let mut profiles = Vec::new();
135    let entries = match fs::read_dir(&dir) {
136        Ok(e) => e,
137        Err(_) => return vec![],
138    };
139
140    for entry in entries.filter_map(|e| e.ok()) {
141        let path = entry.path();
142        let ext = path.extension().and_then(|e| e.to_str());
143        if ext != Some("yaml") && ext != Some("yml") {
144            continue;
145        }
146        let stem = path
147            .file_stem()
148            .unwrap_or_default()
149            .to_string_lossy()
150            .to_string();
151        let desc = fs::read_to_string(&path)
152            .ok()
153            .and_then(|s| serde_yaml::from_str::<Profile>(&s).ok())
154            .and_then(|p| p.description);
155        profiles.push((stem, desc));
156    }
157
158    profiles.sort_by(|a, b| a.0.cmp(&b.0));
159    profiles
160}
161
162/// Get the active profile name (from env var or session file).
163pub fn get_active_profile() -> Option<String> {
164    // Check env var first
165    if let Ok(profile) = std::env::var("CHUB_PROFILE") {
166        if !profile.is_empty() {
167            return Some(profile);
168        }
169    }
170
171    // Check session file
172    let session_path = project_chub_dir()?.join(".active_profile");
173    fs::read_to_string(&session_path)
174        .ok()
175        .map(|s| s.trim().to_string())
176        .filter(|s| !s.is_empty())
177}
178
179/// Set the active profile for this session.
180pub fn set_active_profile(name: Option<&str>) -> Result<()> {
181    let chub_dir = project_chub_dir().ok_or_else(|| {
182        Error::Config("No .chub/ directory found. Run `chub init` first.".to_string())
183    })?;
184
185    let session_path = chub_dir.join(".active_profile");
186
187    match name {
188        Some(n) => {
189            // Validate profile exists
190            let _ = load_profile(n)?;
191            fs::write(&session_path, n)?;
192        }
193        None => {
194            let _ = fs::remove_file(&session_path);
195        }
196    }
197
198    Ok(())
199}
200
201/// Auto-detect profile based on file path and auto_profile config.
202pub fn auto_detect_profile(file_path: &str) -> Option<String> {
203    let project_config = crate::team::project::load_project_config()?;
204    let auto_profiles = project_config.auto_profile?;
205
206    for entry in &auto_profiles {
207        let pattern = format!("**/{}", entry.path);
208        if let Ok(glob) = globset::Glob::new(&pattern) {
209            let matcher = glob.compile_matcher();
210            if matcher.is_match(file_path) {
211                return Some(entry.profile.clone());
212            }
213        }
214        // Simple prefix match fallback
215        let prefix = entry.path.trim_end_matches("**").trim_end_matches('/');
216        if file_path.starts_with(prefix) {
217            return Some(entry.profile.clone());
218        }
219    }
220
221    None
222}