1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::config::Profile;
7use crate::error::{AvError, Result};
8
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct TeamConfig {
12 pub source: Option<String>,
16 pub cache_ttl: Option<u64>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22struct TeamPolicies {
23 #[serde(default)]
24 policies: HashMap<String, Profile>,
25}
26
27#[derive(Debug, Serialize, Deserialize)]
29struct CacheEntry {
30 fetched_at: i64,
31 policies: HashMap<String, Profile>,
32}
33
34fn cache_path() -> PathBuf {
35 dirs::cache_dir()
36 .unwrap_or_else(|| PathBuf::from("."))
37 .join("audex")
38 .join("team_policies.json")
39}
40
41fn load_cache(ttl_secs: u64) -> Option<HashMap<String, Profile>> {
43 let path = cache_path();
44 let json = std::fs::read_to_string(&path).ok()?;
45 let entry: CacheEntry = serde_json::from_str(&json).ok()?;
46 let now = chrono::Utc::now().timestamp();
47 if (now - entry.fetched_at) < ttl_secs as i64 {
48 Some(entry.policies)
49 } else {
50 None
51 }
52}
53
54fn save_cache(policies: &HashMap<String, Profile>) {
56 let entry = CacheEntry {
57 fetched_at: chrono::Utc::now().timestamp(),
58 policies: policies.clone(),
59 };
60 if let Ok(json) = serde_json::to_string(&entry) {
61 let path = cache_path();
62 if let Some(parent) = path.parent() {
63 let _ = std::fs::create_dir_all(parent);
64 }
65 let _ = std::fs::write(path, json);
66 }
67}
68
69pub async fn fetch(config: &TeamConfig) -> Result<HashMap<String, Profile>> {
71 let source = config.source.as_deref().ok_or_else(|| {
72 AvError::InvalidPolicy(
73 "No team policy source configured. Set [team] source = \"https://...\" in config.toml"
74 .to_string(),
75 )
76 })?;
77
78 let ttl = config.cache_ttl.unwrap_or(3600);
79
80 if let Some(cached) = load_cache(ttl) {
82 return Ok(cached);
83 }
84
85 let client = reqwest::Client::new();
87 let resp = client
88 .get(source)
89 .timeout(std::time::Duration::from_secs(10))
90 .send()
91 .await
92 .map_err(|e| {
93 AvError::InvalidPolicy(format!(
94 "Failed to fetch team policies from {}: {}",
95 source, e
96 ))
97 })?;
98
99 if !resp.status().is_success() {
100 return Err(AvError::InvalidPolicy(format!(
101 "Team policy source returned {}: {}",
102 resp.status(),
103 source
104 )));
105 }
106
107 let body = resp
108 .text()
109 .await
110 .map_err(|e| AvError::InvalidPolicy(format!("Failed to read team policies: {}", e)))?;
111
112 let team_policies: TeamPolicies = toml::from_str(&body)
113 .map_err(|e| AvError::InvalidPolicy(format!("Invalid team policies TOML: {}", e)))?;
114
115 save_cache(&team_policies.policies);
117
118 Ok(team_policies.policies)
119}
120
121pub async fn resolve(config: &TeamConfig, name: &str) -> Result<Profile> {
123 let key = name.strip_prefix("team://").unwrap_or(name);
124 let policies = fetch(config).await?;
125
126 policies.get(key).cloned().ok_or_else(|| {
127 let available: Vec<String> = policies.keys().cloned().collect();
128 AvError::InvalidPolicy(format!(
129 "Unknown team policy '{}'. Available: {}",
130 key,
131 if available.is_empty() {
132 "(none)".to_string()
133 } else {
134 available.join(", ")
135 }
136 ))
137 })
138}
139
140pub fn resolve_cached(name: &str) -> Result<Profile> {
142 let key = name.strip_prefix("team://").unwrap_or(name);
143 let ttl = 3600; let policies = load_cache(ttl).ok_or_else(|| {
146 AvError::InvalidPolicy(
147 "No cached team policies. Run `tryaudex run` once to fetch them.".to_string(),
148 )
149 })?;
150
151 policies
152 .get(key)
153 .cloned()
154 .ok_or_else(|| AvError::InvalidPolicy(format!("Unknown team policy '{}'", key)))
155}
156
157pub fn list_cached() -> Vec<(String, Profile)> {
159 load_cache(u64::MAX)
160 .unwrap_or_default()
161 .into_iter()
162 .collect()
163}