1use 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}