Skip to main content

browser_control/cli/
targets.rs

1//! `browser-control targets` — list page targets in the active browser.
2
3use anyhow::{bail, Result};
4
5use crate::cli::env_resolver;
6use crate::cli::mcp::resolve_browser;
7use crate::cli::output::{print_json, print_table, terminal_width};
8use crate::session::targets::{self, TargetInfo};
9
10pub async fn run(browser: Option<String>, url: Option<String>, json: bool) -> Result<()> {
11    if let Some(ref raw) = browser {
12        let parsed = env_resolver::parse_target(raw)?;
13        if parsed.tab.is_some() {
14            bail!(
15                "`targets` operates browser-wide; tab suffixes are not supported \
16                 (got `{raw}`). Use a bare browser selector instead."
17            );
18        }
19    }
20    let resolved = resolve_browser(browser).await?;
21    let targets = targets::list(&resolved.endpoint, resolved.engine, url.as_deref()).await?;
22    let mut out = std::io::stdout();
23    if json {
24        print_json(&mut out, &targets)?;
25    } else {
26        let headers = ["KIND", "ID", "URL", "TITLE"];
27        let rows = table_rows(&targets, terminal_width());
28        print_table(&mut out, &headers, &rows)?;
29    }
30    Ok(())
31}
32
33fn table_rows(targets: &[TargetInfo], terminal_width: usize) -> Vec<Vec<String>> {
34    let limits = target_table_limits(targets, terminal_width);
35    targets
36        .iter()
37        .map(|t| {
38            vec![
39                truncate_middle(&t.kind, limits.kind),
40                truncate_middle(&t.id, limits.id),
41                truncate_middle(&t.url, limits.url),
42                truncate_middle(&t.title, limits.title),
43            ]
44        })
45        .collect()
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49struct TargetTableLimits {
50    kind: usize,
51    id: usize,
52    url: usize,
53    title: usize,
54}
55
56fn target_table_limits(targets: &[TargetInfo], terminal_width: usize) -> TargetTableLimits {
57    const GUTTER_WIDTH: usize = 2 * 3;
58    const MAX_ID_WIDTH: usize = 32;
59    const MIN_URL_WIDTH: usize = 24;
60    const MIN_TITLE_WIDTH: usize = 12;
61
62    let kind = max_col_len("KIND", targets.iter().map(|t| t.kind.as_str()));
63    let id_available = terminal_width
64        .saturating_sub(kind + GUTTER_WIDTH + MIN_URL_WIDTH + MIN_TITLE_WIDTH)
65        .max("ID".len());
66    let id = max_col_len("ID", targets.iter().map(|t| t.id.as_str()))
67        .min(MAX_ID_WIDTH)
68        .min(id_available);
69    let raw_url = max_col_len("URL", targets.iter().map(|t| t.url.as_str()));
70    let raw_title = max_col_len("TITLE", targets.iter().map(|t| t.title.as_str()));
71    let remaining = terminal_width.saturating_sub(kind + id + GUTTER_WIDTH);
72
73    if raw_url + raw_title <= remaining {
74        return TargetTableLimits {
75            kind,
76            id,
77            url: raw_url,
78            title: raw_title,
79        };
80    }
81
82    let mut title = raw_title
83        .min(remaining / 3)
84        .max(MIN_TITLE_WIDTH.min(remaining));
85    let mut url = raw_url.min(remaining.saturating_sub(title));
86
87    if url < MIN_URL_WIDTH.min(remaining) {
88        url = MIN_URL_WIDTH.min(remaining);
89        title = remaining.saturating_sub(url).max("TITLE".len());
90    }
91
92    TargetTableLimits {
93        kind,
94        id,
95        url: url.max("URL".len()),
96        title: title.max("TITLE".len()),
97    }
98}
99
100fn max_col_len<'a>(header: &str, cells: impl Iterator<Item = &'a str>) -> usize {
101    cells.map(str::len).max().unwrap_or(0).max(header.len())
102}
103
104fn truncate_middle(s: &str, max_len: usize) -> String {
105    let char_len = s.chars().count();
106    if char_len <= max_len {
107        return s.to_string();
108    }
109    if max_len <= 3 {
110        return ".".repeat(max_len);
111    }
112
113    let keep = max_len - 3;
114    let head = keep / 2 + keep % 2;
115    let tail = keep / 2;
116    let head_part: String = s.chars().take(head).collect();
117    let tail_part: String = s
118        .chars()
119        .rev()
120        .take(tail)
121        .collect::<Vec<_>>()
122        .into_iter()
123        .rev()
124        .collect();
125    format!("{head_part}...{tail_part}")
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::detect::Engine;
132    use futures_util::{SinkExt, StreamExt};
133    use serde_json::{json, Value};
134    use tokio_tungstenite::tungstenite::Message;
135
136    async fn spawn_cdp_mock(targets: Vec<Value>) -> String {
137        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
138        let addr = listener.local_addr().unwrap();
139        tokio::spawn(async move {
140            let (stream, _) = listener.accept().await.unwrap();
141            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
142            while let Some(Ok(Message::Text(t))) = ws.next().await {
143                let req: Value = serde_json::from_str(&t).unwrap();
144                let id = req["id"].as_u64().unwrap();
145                let method = req["method"].as_str().unwrap_or("");
146                let result = if method == "Target.getTargets" {
147                    json!({"targetInfos": targets.clone()})
148                } else {
149                    json!({})
150                };
151                let resp = json!({"id": id, "result": result});
152                ws.send(Message::Text(resp.to_string())).await.unwrap();
153            }
154        });
155        format!("ws://{addr}")
156    }
157
158    #[test]
159    fn table_rows_have_kind_id_url_title_in_order() {
160        let t = vec![TargetInfo {
161            id: "abc".into(),
162            url: "https://example.com/".into(),
163            title: "Example".into(),
164            kind: "page".into(),
165        }];
166        let rows = table_rows(&t, 120);
167        assert_eq!(rows.len(), 1);
168        assert_eq!(
169            rows[0],
170            vec!["page", "abc", "https://example.com/", "Example"]
171        );
172    }
173
174    #[test]
175    fn table_rows_truncate_long_urls_for_terminal_width() {
176        let long_url = format!("https://example.com/path?{}", "x".repeat(120));
177        let t = vec![TargetInfo {
178            id: "0123456789abcdef0123456789abcdef".into(),
179            url: long_url.clone(),
180            title: "A long page title".into(),
181            kind: "page".into(),
182        }];
183        let rows = table_rows(&t, 80);
184        assert_eq!(rows.len(), 1);
185        assert!(rows[0][2].len() < long_url.len(), "row: {:?}", rows[0]);
186        assert!(rows[0][2].contains("..."), "row: {:?}", rows[0]);
187
188        let headers = ["KIND", "ID", "URL", "TITLE"];
189        let mut tbuf: Vec<u8> = Vec::new();
190        print_table(&mut tbuf, &headers, &rows).unwrap();
191        let text = String::from_utf8(tbuf).unwrap();
192        assert!(
193            text.lines().all(|line| line.len() <= 80),
194            "table exceeded width:\n{text}"
195        );
196    }
197
198    #[tokio::test]
199    async fn lists_targets_against_cdp_mock_json_and_table() {
200        let endpoint = spawn_cdp_mock(vec![
201            json!({"targetId":"a","type":"page","url":"https://example.com/","title":"Ex"}),
202            json!({"targetId":"b","type":"page","url":"https://other.test/","title":"Other"}),
203        ])
204        .await;
205
206        let targets = targets::list(&endpoint, Engine::Cdp, None).await.unwrap();
207        assert_eq!(targets.len(), 2);
208
209        let mut buf: Vec<u8> = Vec::new();
210        print_json(&mut buf, &targets).unwrap();
211        let v: Value = serde_json::from_slice(&buf).unwrap();
212        for i in 0..2 {
213            for key in ["id", "url", "title", "kind"] {
214                assert!(v[i].get(key).is_some(), "missing {key} in {v:?}");
215            }
216        }
217        assert_eq!(v[0]["url"], "https://example.com/");
218
219        let headers = ["KIND", "ID", "URL", "TITLE"];
220        let rows = table_rows(&targets, 120);
221        let mut tbuf: Vec<u8> = Vec::new();
222        print_table(&mut tbuf, &headers, &rows).unwrap();
223        let text = String::from_utf8(tbuf).unwrap();
224        assert!(text.contains("https://example.com/"));
225        assert!(text.contains("KIND"));
226        assert!(text.contains("page"));
227    }
228}