Skip to main content

tryaudex_core/
team.rs

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/// Team policy configuration in `[team]` config section.
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct TeamConfig {
12    /// URL to fetch team policies from.
13    /// Supports https:// URLs pointing to a TOML file.
14    /// Example: "https://raw.githubusercontent.com/org/policies/main/audex-policies.toml"
15    pub source: Option<String>,
16    /// Cache TTL in seconds (default: 3600 = 1 hour)
17    pub cache_ttl: Option<u64>,
18}
19
20/// Team policies TOML format — fetched from the remote source.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22struct TeamPolicies {
23    #[serde(default)]
24    policies: HashMap<String, Profile>,
25}
26
27/// Cache entry stored on disk.
28#[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
41/// Load cached team policies if still valid.
42fn 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
54/// Save team policies to cache.
55fn 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
69/// Fetch team policies from the configured source URL.
70pub 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    // Check cache first
81    if let Some(cached) = load_cache(ttl) {
82        return Ok(cached);
83    }
84
85    // Fetch from URL
86    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    // Cache for next time
116    save_cache(&team_policies.policies);
117
118    Ok(team_policies.policies)
119}
120
121/// Resolve a team policy by name. Requires async fetch.
122pub 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
140/// Resolve from cache only (sync, for use in non-async contexts).
141pub fn resolve_cached(name: &str) -> Result<Profile> {
142    let key = name.strip_prefix("team://").unwrap_or(name);
143    let ttl = 3600; // default 1 hour
144
145    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
157/// List cached team policies (sync).
158pub fn list_cached() -> Vec<(String, Profile)> {
159    load_cache(u64::MAX)
160        .unwrap_or_default()
161        .into_iter()
162        .collect()
163}