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| AvError::InvalidPolicy(format!("Failed to fetch team policies from {}: {}", source, e)))?;
93
94    if !resp.status().is_success() {
95        return Err(AvError::InvalidPolicy(format!(
96            "Team policy source returned {}: {}",
97            resp.status(),
98            source
99        )));
100    }
101
102    let body = resp
103        .text()
104        .await
105        .map_err(|e| AvError::InvalidPolicy(format!("Failed to read team policies: {}", e)))?;
106
107    let team_policies: TeamPolicies = toml::from_str(&body).map_err(|e| {
108        AvError::InvalidPolicy(format!("Invalid team policies TOML: {}", e))
109    })?;
110
111    // Cache for next time
112    save_cache(&team_policies.policies);
113
114    Ok(team_policies.policies)
115}
116
117/// Resolve a team policy by name. Requires async fetch.
118pub async fn resolve(config: &TeamConfig, name: &str) -> Result<Profile> {
119    let key = name.strip_prefix("team://").unwrap_or(name);
120    let policies = fetch(config).await?;
121
122    policies.get(key).cloned().ok_or_else(|| {
123        let available: Vec<String> = policies.keys().cloned().collect();
124        AvError::InvalidPolicy(format!(
125            "Unknown team policy '{}'. Available: {}",
126            key,
127            if available.is_empty() {
128                "(none)".to_string()
129            } else {
130                available.join(", ")
131            }
132        ))
133    })
134}
135
136/// Resolve from cache only (sync, for use in non-async contexts).
137pub fn resolve_cached(name: &str) -> Result<Profile> {
138    let key = name.strip_prefix("team://").unwrap_or(name);
139    let ttl = 3600; // default 1 hour
140
141    let policies = load_cache(ttl).ok_or_else(|| {
142        AvError::InvalidPolicy(
143            "No cached team policies. Run `tryaudex run` once to fetch them.".to_string(),
144        )
145    })?;
146
147    policies.get(key).cloned().ok_or_else(|| {
148        AvError::InvalidPolicy(format!("Unknown team policy '{}'", key))
149    })
150}
151
152/// List cached team policies (sync).
153pub fn list_cached() -> Vec<(String, Profile)> {
154    load_cache(u64::MAX)
155        .unwrap_or_default()
156        .into_iter()
157        .collect()
158}