use anyhow::Result;
use serde::{Deserialize, Serialize};
const TABLE: &str = "settings";
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct TeamSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm_base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embed_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enrich: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flows: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub guide: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_enrich: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_flows: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm_concurrency: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm_price_in: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm_price_out: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scip: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_index: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_index_llm: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_index_minutes: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repos: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub teams: Option<Vec<TeamDef>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TeamDef {
pub name: String,
#[serde(default)]
pub repos: Vec<String>,
#[serde(default)]
pub members: Vec<String>,
#[serde(default)]
pub incoming: Incoming,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Incoming {
Open,
#[default]
Review,
Closed,
}
pub const UNASSIGNED: &str = "none";
impl TeamSettings {
pub fn team_list(&self) -> &[TeamDef] {
self.teams.as_deref().unwrap_or_default()
}
pub fn teams_of(&self, repo: &str) -> Vec<String> {
self.team_list()
.iter()
.filter(|t| t.repos.iter().any(|r| r == repo))
.map(|t| t.name.clone())
.collect()
}
pub fn outside(&self, writer: &[String], repos: &[&str]) -> Option<Vec<String>> {
if writer.is_empty() {
return None;
}
let mut owners: Vec<String> = Vec::new();
for r in repos {
for t in self.teams_of(r) {
if !owners.contains(&t) {
owners.push(t);
}
}
}
let inside = owners
.iter()
.any(|o| writer.iter().any(|w| w.eq_ignore_ascii_case(o)));
(!owners.is_empty() && !inside).then_some(owners)
}
pub fn accepts(&self, writer: &[String], repos: &[&str]) -> Result<()> {
if let Some(owners) = self.outside(writer, repos) {
let (takes, their) = if owners.len() == 1 {
("takes", "its")
} else {
("take", "their")
};
anyhow::ensure!(
self.incoming(&owners) != Incoming::Closed,
"{} {takes} notes on this code only from {their} members; ask one of them to record it",
owners.join(" and ")
);
}
Ok(())
}
pub fn incoming(&self, owners: &[String]) -> Incoming {
self.team_list()
.iter()
.filter(|t| owners.iter().any(|o| o.eq_ignore_ascii_case(&t.name)))
.map(|t| t.incoming)
.min()
.unwrap_or_default()
}
pub fn teams_of_member(&self, email: &str) -> Vec<String> {
let email = email.trim().to_lowercase();
self.team_list()
.iter()
.filter(|t| t.members.contains(&email))
.map(|t| t.name.clone())
.collect()
}
pub fn set_member(&mut self, email: &str, teams: &[String]) -> Result<()> {
let email = email.trim().to_lowercase();
for n in teams {
anyhow::ensure!(
self.team_list()
.iter()
.any(|t| t.name.eq_ignore_ascii_case(n.trim())),
"there is no team called {n}"
);
}
for t in self.teams.iter_mut().flatten() {
let want = teams.iter().any(|n| n.trim().eq_ignore_ascii_case(&t.name));
t.members.retain(|m| *m != email);
if want {
t.members.push(email.clone());
}
}
Ok(())
}
pub fn in_view(&self, repo: &str, selected: &[String]) -> bool {
if selected.is_empty() {
return true;
}
let labels = self.teams_of(repo);
selected.iter().any(|s| match s.as_str() {
UNASSIGNED => labels.is_empty(),
name => labels.iter().any(|l| l.eq_ignore_ascii_case(name)),
})
}
pub fn tidy_teams(&mut self) -> Result<()> {
let Some(teams) = self.teams.as_mut() else {
return Ok(());
};
let mut seen = std::collections::HashSet::new();
for t in teams.iter_mut() {
t.name = t.name.trim().to_string();
anyhow::ensure!(!t.name.is_empty(), "a team needs a name");
anyhow::ensure!(
!t.name.eq_ignore_ascii_case(UNASSIGNED),
"`{UNASSIGNED}` is reserved for repositories without a team"
);
anyhow::ensure!(
seen.insert(t.name.to_lowercase()),
"two teams are called {}",
t.name
);
let mut repos = std::collections::BTreeSet::new();
t.repos.retain(|r| repos.insert(r.clone()));
for m in t.members.iter_mut() {
*m = m.trim().to_lowercase();
}
let mut members = std::collections::BTreeSet::new();
t.members
.retain(|m| !m.is_empty() && members.insert(m.clone()));
}
Ok(())
}
pub fn unlabel(&mut self, repo: &str) {
for t in self.teams.iter_mut().flatten() {
t.repos.retain(|r| r != repo);
}
}
pub fn label(&mut self, repo: &str, teams: &[String]) {
let list = self.teams.get_or_insert_with(Vec::new);
for t in list.iter_mut() {
let want = teams.iter().any(|n| n.trim().eq_ignore_ascii_case(&t.name));
let has = t.repos.iter().any(|r| r == repo);
match (want, has) {
(true, false) => t.repos.push(repo.to_string()),
(false, true) => t.repos.retain(|r| r != repo),
_ => {}
}
}
for n in teams.iter().map(|n| n.trim()).filter(|n| !n.is_empty()) {
if !list.iter().any(|t| t.name.eq_ignore_ascii_case(n)) {
list.push(TeamDef {
name: n.to_string(),
repos: vec![repo.to_string()],
..Default::default()
});
}
}
}
pub fn backfill(&self, repos: &[String], members: &[String]) -> Option<TeamSettings> {
let name = self.name.as_deref()?.trim();
if self.teams.is_some() || name.is_empty() {
return None;
}
let mut repos = repos.to_vec();
repos.sort();
repos.dedup();
let mut next = TeamSettings {
teams: Some(vec![TeamDef {
name: name.to_string(),
repos,
members: members.to_vec(),
..Default::default()
}]),
..self.clone()
};
next.tidy_teams().ok()?;
Some(next)
}
}
pub fn parse_selection(raw: Option<&str>) -> Vec<String> {
raw.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
#[derive(Debug, Clone, Default)]
pub struct Stored {
pub settings: TeamSettings,
pub version: Option<u64>,
pub changed_by: Option<String>,
pub changed_at: Option<String>,
}
fn uri(root: &str) -> String {
format!("{}/_team", root.trim_end_matches('/'))
}
pub async fn load(root: &str) -> Result<Stored> {
let s = crate::docstore::load::<TeamSettings>(&uri(root), TABLE).await?;
Ok(Stored {
settings: s.doc,
version: s.version,
changed_by: s.changed_by,
changed_at: s.changed_at,
})
}
pub async fn save_as(
root: &str,
settings: &TeamSettings,
expected: Option<u64>,
by: &str,
) -> Result<u64> {
crate::docstore::save(&uri(root), TABLE, settings, expected, by)
.await
.map_err(|e| match format!("{e}").contains("changed meanwhile") {
true => anyhow::anyhow!(
"the team settings were changed by someone else; reload and try again"
),
false => e,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn save_load_and_refuse_a_stale_write() {
let dir = std::env::temp_dir().join(format!("comindly-team-{}", std::process::id()));
let root = dir.to_str().unwrap();
let first = TeamSettings {
llm_model: Some("m1".into()),
..Default::default()
};
let v1 = save_as(root, &first, None, "t").await.unwrap();
assert_eq!(load(root).await.unwrap().settings, first);
let second = TeamSettings {
llm_model: Some("m2".into()),
..Default::default()
};
save_as(root, &second, Some(v1), "t").await.unwrap();
assert!(
save_as(root, &first, Some(v1), "t").await.is_err(),
"stale version"
);
assert_eq!(
load(root).await.unwrap().settings.llm_model.as_deref(),
Some("m2")
);
std::fs::remove_dir_all(&dir).ok();
}
fn two_teams() -> TeamSettings {
let mut t = TeamSettings::default();
t.label("catalog", &["Search".into()]);
t.label("shared-lib", &["Search".into(), "Backend".into()]);
t.label("billing", &["Backend".into()]);
t
}
#[test]
fn labels_select_repositories() {
let t = two_teams();
assert_eq!(t.teams_of("shared-lib"), ["Search", "Backend"]);
let search = vec!["search".to_string()];
assert!(t.in_view("catalog", &search) && t.in_view("shared-lib", &search));
assert!(!t.in_view("billing", &search));
assert!(t.in_view("billing", &[]), "no selection shows everything");
let none = vec![UNASSIGNED.to_string()];
assert!(t.in_view("docs", &none) && !t.in_view("catalog", &none));
}
#[test]
fn relabel_and_unlabel() {
let mut t = two_teams();
t.label("shared-lib", &["Backend".into()]);
assert_eq!(t.teams_of("shared-lib"), ["Backend"]);
t.unlabel("billing");
assert!(t.teams_of("billing").is_empty());
assert_eq!(t.team_list().len(), 2, "an emptied team stays");
}
#[test]
fn tidy_refuses_duplicates_and_the_reserved_name() {
let mut t = two_teams();
t.teams.as_mut().unwrap().push(TeamDef {
name: " search ".into(),
..Default::default()
});
assert!(t.tidy_teams().is_err());
let mut t = TeamSettings {
teams: Some(vec![TeamDef {
name: "None".into(),
..Default::default()
}]),
..Default::default()
};
assert!(t.tidy_teams().is_err());
}
#[test]
fn closed_teams_refuse_notes_from_outside() {
let mut t = TeamSettings::default();
t.label("api", &["Backend".into()]);
t.label("pay", &["Payments".into(), "Backend".into()]);
let search = ["Search".to_string()];
assert_eq!(
t.outside(&search, &["api"]),
Some(vec!["Backend".to_string()])
);
assert_eq!(t.outside(&["backend".into()], &["api"]), None);
assert_eq!(t.outside(&[], &["api"]), None);
assert_eq!(t.outside(&search, &["unlabelled"]), None);
t.teams.as_mut().unwrap()[0].incoming = Incoming::Closed;
assert!(t.accepts(&search, &["api"]).is_err());
assert!(t.accepts(&search, &["pay"]).is_ok());
assert!(t.accepts(&["Payments".into()], &["api"]).is_err());
}
#[test]
fn backfill_only_once() {
let org = TeamSettings {
name: Some("Search".into()),
..Default::default()
};
let done = org
.backfill(&["b".into(), "a".into(), "a".into()], &["Ann@X.io".into()])
.unwrap();
assert_eq!(done.team_list()[0].name, "Search");
assert_eq!(done.team_list()[0].repos, ["a", "b"]);
assert_eq!(done.teams_of_member("ann@x.io"), ["Search"]);
assert!(
done.backfill(&["c".into()], &[]).is_none(),
"already set up"
);
let emptied = TeamSettings {
teams: Some(vec![]),
..org.clone()
};
assert!(
emptied.backfill(&["a".into()], &[]).is_none(),
"deliberately none"
);
assert!(
TeamSettings::default()
.backfill(&["a".into()], &[])
.is_none(),
"no org name"
);
}
#[test]
fn members_pick_existing_teams() {
let mut t = two_teams();
t.set_member("Ann@x.io", &["search".into(), "Backend".into()])
.unwrap();
assert_eq!(t.teams_of_member("ann@x.io"), ["Search", "Backend"]);
t.set_member("ann@x.io", &["Backend".into()]).unwrap();
assert_eq!(t.teams_of_member("ann@x.io"), ["Backend"]);
assert!(t.set_member("ann@x.io", &["Nope".into()]).is_err());
}
}