chub_core/team/
profiles.rs1use 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#[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#[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
39pub 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
59pub 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(¤t) {
68 return Err(Error::Config(format!(
69 "Circular profile inheritance detected: {}",
70 chain.join(" → ")
71 )));
72 }
73 let profile = load_profile(¤t)?;
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 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 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 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
127pub 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
162pub fn get_active_profile() -> Option<String> {
164 if let Ok(profile) = std::env::var("CHUB_PROFILE") {
166 if !profile.is_empty() {
167 return Some(profile);
168 }
169 }
170
171 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
179pub 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 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
201pub 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 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}