Skip to main content

light_fw_cli_core/
devtools.rs

1//! ТЗ 0.16.0: клиент DevTools по TCP для `light devtools <cmd>`.
2//!
3//! Подключается к `127.0.0.1:<port>` (по умолчанию 9229), отправляет одну
4//! команду в текстовом виде (line-delimited, `\n`) и читает ответ до
5//! следующего `\n`. Если порт не принимает соединения — ошибка «Приложение не
6//! запущено».
7
8use std::io::Write;
9use std::net::TcpStream;
10use std::time::Duration;
11
12/// Порт DevTools по умолчанию (совпадает с `DEVTOOLS_PORT` в [`crate::dev`]).
13pub const DEFAULT_PORT: u16 = 9229;
14
15/// Ошибка клиента DevTools.
16#[derive(Debug, thiserror::Error)]
17pub enum DevtoolsError {
18    /// Приложение не запущено (порт не отвечает).
19    #[error("Приложение не запущено. Запустите light dev")]
20    NotRunning,
21    /// IO-ошибка (таймаут, разрыв соединения, …).
22    #[error("io: {0}")]
23    Io(#[from] std::io::Error),
24    /// Неверные аргументы команды (например, пустой key).
25    #[error("{0}")]
26    BadArgs(String),
27}
28
29/// Результат операции клиента.
30pub type Result<T> = std::io::Result<T>;
31
32/// Аргументы одной команды `light devtools`.
33#[derive(Debug, Clone)]
34pub struct DevtoolsRequest {
35    /// Имя подкоманды: tree, layout, find, click, type, key, state, shot,
36    /// session.
37    pub cmd: String,
38    /// Строковое тело запроса после имени команды (может быть пустым).
39    pub body: Option<String>,
40    /// Путь для сохранения скриншота (для `shot --path`).
41    pub save_path: Option<std::path::PathBuf>,
42    /// Разрешён ли вывод JSON (`--json`).
43    pub json: bool,
44}
45
46impl DevtoolsRequest {
47    /// Строит текст команды для отправки на сервер. Имя команды передаётся в
48    /// верхнем регистре (протокол DevTools: TREE, LAYOUT, FIND, CLICK, …),
49    /// а тело запроса — как есть (JSON может содержать пробелы).
50    pub fn to_wire(&self) -> String {
51        let name = self.cmd.to_ascii_uppercase();
52        // Специальный случай: CLI-подкоманда `state` → серверная SNAPSHOT.
53        let name = if name == "STATE" {
54            "SNAPSHOT".to_owned()
55        } else {
56            name
57        };
58        match &self.body {
59            Some(b) => format!("{name} {b}"),
60            None => name,
61        }
62    }
63}
64
65/// Отправляет запрос и возвращает сырую строку ответа (без завершающего `\n`).
66///
67/// При невозможности подключиться возвращает [`DevtoolsError::NotRunning`].
68pub fn send_request(
69    port: u16,
70    req: &DevtoolsRequest,
71) -> std::result::Result<String, DevtoolsError> {
72    let addr = format!("127.0.0.1:{port}");
73    let socket: std::net::SocketAddr = addr.parse().map_err(|_| DevtoolsError::NotRunning)?;
74    let stream = TcpStream::connect_timeout(&socket, Duration::from_secs(3))
75        .map_err(|_| DevtoolsError::NotRunning)?;
76    stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
77    let mut buf_reader = std::io::BufReader::new(stream.try_clone()?);
78    let mut writer = stream;
79    let wire = req.to_wire();
80    writer.write_all(wire.as_bytes())?;
81    writer.write_all(b"\n")?;
82    writer.flush()?;
83    let mut line = String::new();
84    // Ответ может быть длинным (base64 PNG) — читаем до `\n` без ограничения
85    // в 8 КБ ( BufRead::read_line умеет большие строки, но используем read_until
86    // вручную, чтобы не зависеть от лимита read_line).
87    use std::io::Read;
88    let mut byte = [0u8; 1];
89    loop {
90        if buf_reader.read(&mut byte[..1])? == 0 {
91            break;
92        }
93        if byte[0] == b'\n' {
94            break;
95        }
96        if byte[0] == b'\r' {
97            continue;
98        }
99        line.push(byte[0] as char);
100    }
101    Ok(line)
102}
103
104/// Путь к целевому PNG: если указан только каталог (существующая директория
105/// или имя без расширения), добавляем `<ts>.png`; иначе путь как есть.
106fn resolve_png_path(path: &std::path::Path) -> std::path::PathBuf {
107    if path.is_dir() {
108        let ts = format_unix_now();
109        path.join(format!("{ts}.png"))
110    } else if path.extension().is_none() {
111        // Имя без расширения → считаем каталогом: `<name>_<ts>.png` внутри.
112        // Для `.` / `` имя заменяем на `screenshot`.
113        let base = path
114            .file_name()
115            .and_then(|n| n.to_str())
116            .filter(|n| !n.is_empty() && *n != ".")
117            .map(String::from)
118            .unwrap_or_else(|| "screenshot".to_owned());
119        path.join(format!("{base}_{}.png", format_unix_now()))
120    } else {
121        path.to_path_buf()
122    }
123}
124
125/// Локальная метка времени для имени файла (YYYYMMDD_HHMMSS), без зависимостей.
126fn format_unix_now() -> String {
127    use std::time::{SystemTime, UNIX_EPOCH};
128    let secs = SystemTime::now()
129        .duration_since(UNIX_EPOCH)
130        .map(|d| d.as_secs())
131        .unwrap_or(0);
132    let days = (secs / 86400) as i64;
133    let sod = (secs % 86400) as u32;
134    let (y, mo, day) = civil_from_days_local(days);
135    format!(
136        "{y:04}{mo:02}{day:02}_{:02}{:02}{:02}",
137        sod / 3600,
138        (sod % 3600) / 60,
139        sod % 60
140    )
141}
142
143/// Алгоритм «civil from days» (Howard Hinnant) — копия из session.rs, чтобы
144/// не тянуть приватную функцию.
145fn civil_from_days_local(z: i64) -> (i64, u32, u32) {
146    let z = z + 719468;
147    let era = if z >= 0 { z } else { z - 146096 } / 146097;
148    let doe = (z - era * 146097) as u64;
149    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
150    let y = yoe as i64 + era * 400;
151    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
152    let mp = (5 * doy + 2) / 153;
153    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
154    let mo = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
155    let y = if mo <= 2 { y + 1 } else { y };
156    (y, mo, d)
157}
158
159/// Проверка ответа сервера: если он НЕ похож на скриншот (`base64:`), CLI
160/// обязан вернуть читаемую ошибку, а не падать с `Invalid symbol` при
161/// декодировании (баг 3 ТЗ 0.16.1).
162pub fn is_screenshot_response(raw: &str) -> bool {
163    raw.starts_with("base64:")
164}
165
166/// Сообщение об ошибке, когда сервер не вернул изображение (узнаётся по
167/// строкам `unknown command`, `error: …`, `timeout` и т.п.).
168pub fn shot_error_message(raw: &str) -> String {
169    let trimmed = raw.trim();
170    if trimmed == "unknown command" {
171        "Сервер не поддерживает команду SHOT (возможно, устаревшая версия приложения)".to_owned()
172    } else if trimmed.starts_with("error:") || trimmed == "timeout" {
173        format!("Ошибка от сервера DevTools: {trimmed}")
174    } else {
175        format!("Ожидался ответ `base64:<png>`, получен: `{trimmed}`")
176    }
177}
178
179/// Декодирует base64-PNG (ответ `base64:<data>`) и сохраняет его в файл.
180/// Возвращает путь к сохранённому файлу.
181///
182/// Баг 2 (ТЗ 0.16.1): если `--path` указывает на существующий каталог (или
183/// имя без расширения), автоматически дописываем `_<ts>.png`. Родительский
184/// каталог создаётся, если отсутствует. Ошибка `os error 5` больше не
185/// возникает, если папка существует и доступна на запись.
186pub fn save_png(
187    base64_payload: &str,
188    path: &std::path::Path,
189) -> std::io::Result<std::path::PathBuf> {
190    use base64::Engine;
191    if !is_screenshot_response(base64_payload) {
192        return Err(std::io::Error::other(shot_error_message(base64_payload)));
193    }
194    let target = resolve_png_path(path);
195    let b64 = base64_payload
196        .strip_prefix("base64:")
197        .unwrap_or(base64_payload);
198    let bytes = base64::engine::general_purpose::STANDARD
199        .decode(b64)
200        .map_err(|e| std::io::Error::other(format!("base64: {e}")))?;
201    if let Some(parent) = target.parent()
202        && !parent.as_os_str().is_empty()
203    {
204        std::fs::create_dir_all(parent)?;
205    }
206    std::fs::write(&target, &bytes)?;
207    Ok(target)
208}
209
210/// Разбирает флаг `--attr k=v` в пару (ключ, значение).
211pub fn parse_attr(s: &str) -> std::result::Result<(String, String), DevtoolsError> {
212    let (k, v) = s
213        .split_once('=')
214        .ok_or_else(|| DevtoolsError::BadArgs(format!("--attr expects k=v, got `{s}`")))?;
215    Ok((k.to_owned(), v.to_owned()))
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    fn req(cmd: &str, body: Option<&str>) -> DevtoolsRequest {
223        DevtoolsRequest {
224            cmd: cmd.to_owned(),
225            body: body.map(|s| s.to_owned()),
226            save_path: None,
227            json: false,
228        }
229    }
230
231    /// Простая команда без тела → верхний регистр имени.
232    #[test]
233    fn to_wire_simple() {
234        assert_eq!(req("tree", None).to_wire(), "TREE");
235        assert_eq!(req("layout", None).to_wire(), "LAYOUT");
236        assert_eq!(req("shot", None).to_wire(), "SHOT");
237    }
238
239    /// Тело передаётся как есть (JSON с пробелами не ломается).
240    #[test]
241    fn to_wire_with_body() {
242        assert_eq!(
243            req("find", Some(r#"{"tag":"button","text":"+1"}"#)).to_wire(),
244            "FIND {\"tag\":\"button\",\"text\":\"+1\"}"
245        );
246        // TYPE: текст с пробелом сохраняется полностью.
247        assert_eq!(
248            req("type", Some("0.1 hello world")).to_wire(),
249            "TYPE 0.1 hello world"
250        );
251        assert_eq!(req("key", Some("0.1 Enter")).to_wire(), "KEY 0.1 Enter");
252        assert_eq!(req("click", Some("0.0")).to_wire(), "CLICK 0.0");
253    }
254
255    /// CLI-подкоманда `state` маппится на серверную SNAPSHOT.
256    #[test]
257    fn to_wire_state_is_snapshot() {
258        assert_eq!(req("state", None).to_wire(), "SNAPSHOT");
259    }
260
261    /// `--attr k=v` разбирается; без `=` — ошибка.
262    #[test]
263    fn parse_attr_roundtrip() {
264        assert_eq!(
265            parse_attr("class=btn").expect("k=v"),
266            ("class".to_owned(), "btn".to_owned())
267        );
268        assert!(parse_attr("no-equals").is_err());
269    }
270
271    /// ТЗ 0.16.1, баг 3: ответ сервера «не скриншот» детектируется.
272    #[test]
273    fn shot_error_detection() {
274        assert!(is_screenshot_response("base64:AAAA"));
275        assert!(!is_screenshot_response("unknown command"));
276        assert!(!is_screenshot_response("timeout"));
277        assert!(!is_screenshot_response("error: no frame"));
278        let m = shot_error_message("unknown command");
279        assert!(m.contains("SHOT"), "{m}");
280        assert!(m.contains("устаревша"), "{m}");
281    }
282
283    /// ТЗ 0.16.1, баг 2: путь-каталог получает имя `<ts>.png`.
284    #[test]
285    fn resolve_png_path_dir_gets_timestamped_name() {
286        let base = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
287        let dir = base.join(format!("_resolve_dir_{}", std::process::id()));
288        let _ = std::fs::remove_dir_all(&dir);
289        std::fs::create_dir_all(&dir).expect("create");
290        let p = resolve_png_path(&dir);
291        assert!(p.starts_with(&dir), "{}", p.display());
292        assert!(
293            p.file_name()
294                .and_then(|n| n.to_str())
295                .unwrap_or("")
296                .ends_with(".png")
297        );
298        let _ = std::fs::remove_dir_all(&dir);
299    }
300
301    /// ТЗ 0.16.1, баг 2: имя без расширения → `<name>_<ts>.png` внутри.
302    #[test]
303    fn resolve_png_path_no_extension() {
304        let p = resolve_png_path(std::path::Path::new("shots/shots"));
305        assert!(
306            p.starts_with(std::path::Path::new("shots/shots")),
307            "{}",
308            p.display()
309        );
310        let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
311        assert!(name.ends_with(".png"), "{name}");
312        assert!(name.starts_with("shots_"), "{name}");
313    }
314
315    /// ТЗ 0.16.1, баг 2: путь с расширением `.png` возвращается как есть.
316    #[test]
317    fn resolve_png_path_with_png_ext() {
318        let p = resolve_png_path(std::path::Path::new("out/frame.png"));
319        assert_eq!(p, std::path::PathBuf::from("out/frame.png"));
320    }
321
322    /// ТЗ 0.16.1, баг 2 + 3: `save_png` в каталог создаёт файл и не падает;
323    /// неизвестная команда → читаемая ошибка, не `Invalid symbol`.
324    #[test]
325    fn save_png_writes_file_and_rejects_unknown_command() {
326        use base64::Engine;
327        // Уникальный каталог рядом с исполняемым файлом — обходим `C:\Temp`
328        // (в этой среде на него нет прав записи, os error 5).
329        let dir = std::env::current_dir()
330            .unwrap_or_else(|_| std::path::PathBuf::from("."))
331            .join(format!("_save_png_test_{}", std::process::id()));
332        let _ = std::fs::remove_dir_all(&dir);
333        std::fs::create_dir_all(&dir).expect("create dir");
334        // Ложные "PNG" данные (не настоящий PNG) — проверяется только round-trip.
335        let png_bytes: Vec<u8> = vec![137, 80, 78, 71, 13, 10, 26, 10, 1, 2, 3, 4];
336        let b64 = format!(
337            "base64:{}",
338            base64::engine::general_purpose::STANDARD.encode(&png_bytes)
339        );
340        let p = save_png(&b64, &dir).expect("save");
341        assert!(p.is_file(), "{} должен существовать", p.display());
342        assert_eq!(std::fs::read(&p).unwrap(), png_bytes);
343        // Баг 3: неизвестная команда → читаемая ошибка, не Invalid symbol.
344        let err = save_png("unknown command", &dir).expect_err("должен быть error");
345        assert!(err.to_string().contains("SHOT"), "{}", err);
346        let _ = std::fs::remove_dir_all(&dir);
347    }
348}