use super::chrome_daemon::{NoVerdict, run_cli_json_at};
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::time::{Duration, Instant};
const AGENT_GROUP_TITLE: &str = "✦ agents";
const PROJECT_MARK: &str = "✦ ";
const TAIL_BUDGET: Duration = Duration::from_secs(5);
const CALL_TIMEOUT: Duration = Duration::from_secs(2);
pub(crate) const STEP_ISSUE_MESSAGE: &str = "a command-line browser step left its tab group wrong";
pub(crate) struct Note {
pub(crate) reason: &'static str,
pub(crate) detail: String,
}
const TABS_UNREAD_REASON: &str = "chrome-tabs:tabs-unread";
const TAB_NOT_MOVED_REASON: &str = "chrome-tabs:tab-not-moved";
const GROUP_NOT_COLLAPSED_REASON: &str = "chrome-tabs:group-not-collapsed";
const GROUP_NOT_NAMED_REASON: &str = "chrome-tabs:agent-group-not-named";
pub(crate) enum Opened<'a> {
Baseline(&'a BTreeSet<i64>),
Tab(&'a str),
}
pub(crate) async fn settle(cli: &Path, session: &str) -> Option<Note> {
let tabs = match own_rows(cli, session).await {
Ok(tabs) => tabs,
Err(note) => return note,
};
let (mut trouble, states) = collapse_each(cli, session, groups_of(&tabs)).await;
if let Some(group) = placement_group(&tabs)
&& let Some(state) = states.get(&group)
&& nameable(state)
&& let Some(detail) = name_group(cli, session).await
{
trouble.get_or_insert(Note {
reason: GROUP_NOT_NAMED_REASON,
detail,
});
}
answered(cli, session, trouble).await
}
pub(crate) async fn leave_command_line_tabs(
cli: &Path,
session: &str,
opened: Opened<'_>,
) -> Option<Note> {
let tabs = match own_rows(cli, session).await {
Ok(tabs) => tabs,
Err(note) => return note,
};
let groups = groups_of(&tabs);
let mut trouble = None;
let targets: Vec<OwnTab> = match opened {
Opened::Baseline(before) => match created_of(tabs) {
Ok(created) => created
.into_iter()
.filter(|tab| tab.group.is_some() && !before.contains(&tab.tab))
.collect(),
Err(reason) => {
trouble = Some(unread(&reason));
Vec::new()
}
},
Opened::Tab(id) => tabs
.into_iter()
.filter(|tab| tab.group.is_some() && tab.session_tab.as_deref() == Some(id))
.filter(|tab| tab.created != Some(false))
.collect(),
};
if !targets.is_empty()
&& let Some(detail) = ungroup(cli, session, &targets, Instant::now() + TAIL_BUDGET).await
{
trouble.get_or_insert(Note {
reason: TAB_NOT_MOVED_REASON,
detail,
});
}
let (collapse_trouble, _) = collapse_each(cli, session, groups).await;
if let Some(note) = collapse_trouble {
trouble.get_or_insert(note);
}
answered(cli, session, trouble).await
}
pub(crate) async fn collapse_own_groups(cli: &Path, session: &str) -> Option<Note> {
let tabs = match own_rows(cli, session).await {
Ok(tabs) => tabs,
Err(note) => return note,
};
let (trouble, _) = collapse_each(cli, session, groups_of(&tabs)).await;
answered(cli, session, trouble).await
}
pub(crate) async fn created_tabs(cli: &Path, session: &str) -> Result<BTreeSet<i64>, Option<Note>> {
let created = match own_rows(cli, session).await {
Ok(tabs) => created_of(tabs),
Err(note) => return Err(note),
};
match created {
Ok(created) => Ok(created.into_iter().map(|tab| tab.tab).collect()),
Err(reason) => Err(answered(cli, session, Some(unread(&reason))).await),
}
}
async fn own_rows(cli: &Path, session: &str) -> Result<Vec<OwnTab>, Option<Note>> {
match list(cli, session, Instant::now() + TAIL_BUDGET).await {
Ok(tabs) => Ok(tabs),
Err(reason) => Err(answered(cli, session, Some(unread(&reason))).await),
}
}
fn unread(reason: &NoVerdict) -> Note {
Note {
reason: TABS_UNREAD_REASON,
detail: format!("the step's own tabs could not be read: {}", reason.text()),
}
}
async fn answered(cli: &Path, session: &str, trouble: Option<Note>) -> Option<Note> {
let trouble = trouble?;
let doorless = crate::tools::chrome_tabs::ownership_door_missing(
cli,
session,
Instant::now() + CALL_TIMEOUT,
)
.await;
(!doorless).then_some(trouble)
}
async fn collapse_each(
cli: &Path,
session: &str,
groups: Vec<i64>,
) -> (Option<Note>, BTreeMap<i64, GroupState>) {
let deadline = Instant::now() + TAIL_BUDGET;
let mut trouble = None;
let mut states = BTreeMap::new();
for group in groups {
let state = match group_state(cli, session, group, deadline).await {
Ok(Some(state)) => state,
Ok(None) => continue,
Err(detail) => {
trouble.get_or_insert(Note {
reason: GROUP_NOT_COLLAPSED_REASON,
detail,
});
continue;
}
};
if !state.collapsed
&& let Some(detail) = write_collapsed(cli, session, group, deadline).await
{
trouble.get_or_insert(Note {
reason: GROUP_NOT_COLLAPSED_REASON,
detail,
});
}
states.insert(group, state);
}
(trouble, states)
}
fn nameable(state: &GroupState) -> bool {
match state.title.as_deref() {
Some(title) => title.starts_with(PROJECT_MARK) && !title.starts_with(AGENT_GROUP_TITLE),
None => true,
}
}
async fn name_group(cli: &Path, session: &str) -> Option<String> {
match call(
cli,
session,
&["session", "name", AGENT_GROUP_TITLE],
Instant::now() + TAIL_BUDGET,
)
.await
{
Ok(answer) => match renamed(&answer) {
Some(true) => None,
Some(false) => Some(format!(
"the browser did not rename the agent group to {AGENT_GROUP_TITLE}"
)),
None => Some(
"the browser's answer to naming the agent group carried no readable verdict"
.to_string(),
),
},
Err(reason) => Some(format!(
"the agent group could not be named: {}",
reason.text()
)),
}
}
fn renamed(answer: &Value) -> Option<bool> {
answer
.get("renamedExistingGroup")
.or_else(|| answer.pointer("/data/renamedExistingGroup"))
.and_then(Value::as_bool)
}
async fn write_collapsed(
cli: &Path,
session: &str,
group: i64,
deadline: Instant,
) -> Option<String> {
let args = json!([group, { "collapsed": true }]).to_string();
let envelope = match call(
cli,
session,
&["extension", "call", "tabGroups.update", &args],
deadline,
)
.await
{
Ok(envelope) => envelope,
Err(reason) => {
return Some(format!(
"group {group} could not be collapsed: {}",
reason.text()
));
}
};
match group_state_of(&envelope) {
Ok(state) if !state.collapsed => Some(format!(
"group {group} did not report collapsed after the write"
)),
Ok(_) => None,
Err(reason) => Some(format!(
"group {group}'s collapsed state carried no readable answer: {}",
reason.text()
)),
}
}
async fn ungroup(
cli: &Path,
session: &str,
opened: &[OwnTab],
deadline: Instant,
) -> Option<String> {
let ids: Vec<i64> = opened.iter().map(|tab| tab.tab).collect();
let args = json!([ids]).to_string();
match call(
cli,
session,
&["extension", "call", "tabs.ungroup", &args],
deadline,
)
.await
{
Ok(_) => None,
Err(reason) => Some(format!(
"the tabs this call opened were left in their group: {}",
reason.text()
)),
}
}
struct OwnTab {
tab: i64,
session_tab: Option<String>,
group: Option<i64>,
created: Option<bool>,
}
fn own_tabs(envelope: &Value) -> Result<Vec<OwnTab>, NoVerdict> {
envelope
.pointer("/data/browserTabs")
.and_then(Value::as_array)
.ok_or(NoVerdict::Unreadable)?
.iter()
.filter(|row| row.pointer("/owner/kind").and_then(Value::as_str) == Some("self"))
.map(own_tab)
.collect()
}
fn own_tab(row: &Value) -> Result<OwnTab, NoVerdict> {
let tab = tab_id_of(row).ok_or(NoVerdict::Unreadable)?;
let group = match row.get("groupId") {
None | Some(Value::Null) => None,
Some(id) => Some(id.as_i64().ok_or(NoVerdict::Unreadable)?),
}
.filter(|id| *id >= 0);
let created = match row.get("ownership").and_then(Value::as_str) {
Some("created") => Some(true),
Some(_) => Some(false),
None => None,
};
Ok(OwnTab {
tab,
session_tab: row.get("tabId").and_then(Value::as_str).map(String::from),
group,
created,
})
}
fn created_of(tabs: Vec<OwnTab>) -> Result<Vec<OwnTab>, NoVerdict> {
let mut created = Vec::new();
for tab in tabs {
match tab.created {
Some(true) => created.push(tab),
Some(false) => {}
None => return Err(NoVerdict::Unreadable),
}
}
Ok(created)
}
fn placement_group(tabs: &[OwnTab]) -> Option<i64> {
let says_created = tabs.iter().any(|tab| tab.created == Some(true));
let mut counts: BTreeMap<i64, usize> = BTreeMap::new();
for tab in tabs {
if let Some(group) = tab.group
&& (!says_created || tab.created == Some(true))
{
*counts.entry(group).or_default() += 1;
}
}
let mut best: Option<(usize, i64)> = None;
for (group, count) in counts {
if best.is_none_or(|(top, _)| count > top) {
best = Some((count, group));
}
}
best.map(|(_, group)| group)
}
fn groups_of(tabs: &[OwnTab]) -> Vec<i64> {
tabs.iter()
.filter_map(|tab| tab.group)
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn tab_id_of(row: &Value) -> Option<i64> {
row.get("chromeTabId").and_then(Value::as_i64).or_else(|| {
row.get("handle")?
.as_str()?
.strip_prefix("chrome-tab:")?
.parse()
.ok()
})
}
struct GroupState {
title: Option<String>,
collapsed: bool,
}
fn group_state_of(envelope: &Value) -> Result<GroupState, NoVerdict> {
let result = envelope
.pointer("/data/result")
.ok_or(NoVerdict::Unreadable)?;
Ok(GroupState {
title: result
.get("title")
.and_then(Value::as_str)
.map(String::from),
collapsed: result
.get("collapsed")
.and_then(Value::as_bool)
.ok_or(NoVerdict::Unreadable)?,
})
}
async fn group_state(
cli: &Path,
session: &str,
group: i64,
deadline: Instant,
) -> Result<Option<GroupState>, String> {
let id = group.to_string();
let unreadable = |reason: &NoVerdict| {
format!(
"group {group}'s live state could not be read: {}",
reason.text()
)
};
let envelope = match call(
cli,
session,
&["extension", "call", "tabGroups.get", &id],
deadline,
)
.await
{
Ok(envelope) => envelope,
Err(reason) if group_gone(&reason) => return Ok(None),
Err(reason) => return Err(unreadable(&reason)),
};
group_state_of(&envelope)
.map(Some)
.map_err(|reason| unreadable(&reason))
}
fn group_gone(reason: &NoVerdict) -> bool {
matches!(reason, NoVerdict::Reported(msg) if msg.contains("No group with id"))
}
async fn list(cli: &Path, session: &str, deadline: Instant) -> Result<Vec<OwnTab>, NoVerdict> {
own_tabs(&call(cli, session, &["tab", "list", "--all"], deadline).await?)
}
async fn call(
cli: &Path,
session: &str,
args: &[&str],
deadline: Instant,
) -> Result<Value, NoVerdict> {
let timeout =
super::chrome_tabs::spawn_budget(deadline, CALL_TIMEOUT).ok_or(NoVerdict::TimedOut)?;
run_cli_json_at(cli, args, Some(session), timeout).await
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn own(tab: i64, group: Option<i64>, created: Option<bool>) -> OwnTab {
OwnTab {
tab,
session_tab: Some(format!("t{tab}")),
group,
created,
}
}
#[test]
fn only_this_sessions_own_tabs_are_read() {
let envelope = json!({
"data": { "browserTabs": [
{"chromeTabId": 1, "tabId": "t1", "groupId": 7, "owner": {"kind": "self"}},
{"chromeTabId": 2, "tabId": "t2", "groupId": 7, "owner": {"kind": "self"}},
{"chromeTabId": 3, "groupId": 9, "owner": {"kind": "user"}},
{"chromeTabId": 4, "groupId": 11, "owner": {"kind": "session", "session": "other"}},
{"chromeTabId": 5, "owner": {"kind": "self"}},
{"handle": "chrome-tab:6", "groupId": 9, "owner": {"kind": "self"}},
{"chromeTabId": 7, "groupId": -1, "owner": {"kind": "self"}},
{"chromeTabId": 8, "groupId": null, "owner": {"kind": "self"}}
]}
});
let tabs = own_tabs(&envelope).expect("readable");
assert_eq!(groups_of(&tabs), [7, 9]);
let ids: Vec<(i64, Option<&str>)> = tabs
.iter()
.map(|tab| (tab.tab, tab.session_tab.as_deref()))
.collect();
assert_eq!(
ids,
[
(1, Some("t1")),
(2, Some("t2")),
(5, None),
(6, None),
(7, None),
(8, None)
]
);
for unreadable in [
json!({ "data": {} }),
json!({ "data": { "browserTabs": [
{"chromeTabId": 1, "groupId": "7", "owner": {"kind": "self"}}
]}}),
] {
assert!(matches!(own_tabs(&unreadable), Err(NoVerdict::Unreadable)));
}
}
#[test]
fn created_rows_are_this_sessions_own_and_nothing_else() {
let envelope = json!({
"data": { "browserTabs": [
{"chromeTabId": 1, "groupId": 7, "owner": {"kind": "self"}, "ownership": "created"},
{"chromeTabId": 2, "owner": {"kind": "self"}, "ownership": "created"},
{"chromeTabId": 3, "groupId": -1, "owner": {"kind": "self"}, "ownership": "created"},
{"handle": "chrome-tab:4", "groupId": 7, "owner": {"kind": "self"}, "ownership": "created"},
{"chromeTabId": 5, "groupId": 7, "owner": {"kind": "self"}, "ownership": "adopted"},
{"chromeTabId": 6, "groupId": 9, "owner": {"kind": "user"}},
{"chromeTabId": 7, "groupId": 11, "owner": {"kind": "session", "session": "other"}}
]}
});
let created = created_of(own_tabs(&envelope).expect("readable")).expect("readable");
let named: Vec<(i64, Option<i64>)> =
created.iter().map(|tab| (tab.tab, tab.group)).collect();
assert_eq!(named, [(1, Some(7)), (2, None), (3, None), (4, Some(7))]);
for unreadable in [
json!({ "data": {} }),
json!({ "data": { "browserTabs": [
{"chromeTabId": 1, "owner": {"kind": "self"}}
]}}),
json!({ "data": { "browserTabs": [
{"groupId": 7, "owner": {"kind": "self"}, "ownership": "created"}
]}}),
] {
assert!(matches!(
own_tabs(&unreadable).and_then(created_of),
Err(NoVerdict::Unreadable)
));
}
}
#[test]
fn a_sessions_placement_is_the_group_most_created_tabs_sit_in() {
let tabs = [
own(1, Some(7), Some(true)),
own(2, Some(7), Some(true)),
own(3, Some(9), Some(true)),
own(4, Some(4), Some(false)),
];
assert_eq!(placement_group(&tabs), Some(7));
let tie = [own(1, Some(7), Some(true)), own(2, Some(3), Some(true))];
assert_eq!(placement_group(&tie), Some(3));
let older_relay = [
own(1, None, None),
own(2, Some(5), None),
own(3, Some(5), None),
];
assert_eq!(placement_group(&older_relay), Some(5));
assert_eq!(placement_group(&[own(1, None, Some(true))]), None);
}
#[test]
fn group_state_needs_a_collapsed_flag() {
let state = group_state_of(
&json!({ "data": { "result": { "title": "✦ agents", "collapsed": false } } }),
)
.expect("readable");
assert_eq!(state.title.as_deref(), Some("✦ agents"));
assert!(!state.collapsed);
let untitled = group_state_of(&json!({ "data": { "result": { "collapsed": true } } }))
.expect("readable");
assert_eq!(untitled.title, None);
assert!(untitled.collapsed);
for unreadable in [
json!({ "data": { "result": { "title": "✦ agents" } } }),
json!({ "data": { "result": null } }),
] {
assert!(matches!(
group_state_of(&unreadable),
Err(NoVerdict::Unreadable)
));
}
}
}