1use std::sync::{Arc, Mutex};
2use std::time::Duration;
3
4use crate::error::RuntimeError;
5use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
6use crate::value::Value;
7
8#[derive(Debug, Clone)]
9pub struct PreviewConfig {
10 pub base_url: String,
11 pub timeout_ms: u64,
12 pub project_abs_path: String,
13 pub project_hint_slug: Option<String>,
14 pub max_body_bytes: usize,
15}
16
17impl Default for PreviewConfig {
18 fn default() -> Self {
19 Self {
20 base_url: "http://127.0.0.1:65097".into(),
21 timeout_ms: 3000,
22 project_abs_path: std::env::current_dir()
23 .map(|p| p.display().to_string())
24 .unwrap_or_default(),
25 project_hint_slug: None,
26 max_body_bytes: 1_000_000,
27 }
28 }
29}
30
31pub struct PreviewPush {
32 config: Arc<PreviewConfig>,
33 client: reqwest::Client,
34 project_id: Mutex<Option<String>>,
35 startup_lock: tokio::sync::Mutex<()>,
36}
37
38impl PreviewPush {
39 pub fn new(config: PreviewConfig) -> Self {
40 let client = reqwest::Client::builder()
41 .timeout(Duration::from_millis(config.timeout_ms))
42 .build()
43 .expect("build reqwest client");
44 Self {
45 config: Arc::new(config),
46 client,
47 project_id: Mutex::new(None),
48 startup_lock: tokio::sync::Mutex::new(()),
49 }
50 }
51
52 async fn ensure_project(&self) -> ResolveOutcome<String> {
53 if let Some(pid) = self.project_id.lock().unwrap().clone() {
54 return ResolveOutcome::Ok(pid);
55 }
56 let mut body = serde_json::Map::new();
57 body.insert(
58 "abs_path".into(),
59 serde_json::Value::String(self.config.project_abs_path.clone()),
60 );
61 if let Some(slug) = &self.config.project_hint_slug {
62 body.insert("hint_slug".into(), serde_json::Value::String(slug.clone()));
63 }
64 let url = format!("{}/api/projects", self.config.base_url);
65 match self.post_json(&url, &serde_json::Value::Object(body)).await {
66 ResolveOutcome::Ok(r) if r.status().is_success() => {
67 let json: serde_json::Value = match r.json().await {
68 Ok(v) => v,
69 Err(e) => return ResolveOutcome::Fail(format!("decode projects: {e}")),
70 };
71 let pid = json
72 .get("id")
73 .and_then(|v| v.as_str())
74 .unwrap_or_default()
75 .to_string();
76 if pid.is_empty() {
77 return ResolveOutcome::Fail("register response missing id".into());
78 }
79 *self.project_id.lock().unwrap() = Some(pid.clone());
80 ResolveOutcome::Ok(pid)
81 }
82 ResolveOutcome::Ok(r) => ResolveOutcome::Fail(format!(
83 "register project http {}: {}",
84 r.status(),
85 r.text().await.unwrap_or_default()
86 )),
87 ResolveOutcome::Unavailable => ResolveOutcome::Unavailable,
88 ResolveOutcome::Fail(message) => {
89 ResolveOutcome::Fail(format!("register project {message}"))
90 }
91 }
92 }
93
94 async fn post_json(
95 &self,
96 url: &str,
97 body: &serde_json::Value,
98 ) -> ResolveOutcome<reqwest::Response> {
99 let mut response = self.client.post(url).json(body).send().await;
100 if response.as_ref().err().is_some_and(is_connection_refused) {
101 match self.ensure_local_server().await {
102 Ok(true) => response = self.client.post(url).json(body).send().await,
103 Ok(false) => {}
104 Err(message) => return ResolveOutcome::Fail(message),
105 }
106 }
107 match response {
108 Ok(response) => ResolveOutcome::Ok(response),
109 Err(error) if is_connection_refused(&error) => ResolveOutcome::Unavailable,
110 Err(error) => ResolveOutcome::Fail(format!("net: {error}")),
111 }
112 }
113
114 async fn ensure_local_server(&self) -> Result<bool, String> {
115 if self.config.base_url != "http://127.0.0.1:65097"
116 && self.config.base_url != "http://localhost:65097"
117 {
118 return Ok(false);
119 }
120 let _guard = self.startup_lock.lock().await;
121 if matches!(ping(&self.config.base_url, 300).await, PingResult::Ok) {
122 return Ok(true);
123 }
124 let executable = std::env::current_exe().map_err(|error| error.to_string())?;
125 let name = executable
126 .file_name()
127 .and_then(|name| name.to_str())
128 .unwrap_or("");
129 let mut command = std::process::Command::new(&executable);
130 match name {
131 "atman" => {
132 command.args(["preview", "serve", "--port", "65097"]);
133 }
134 "atman-daemon" => {
135 command.arg("--preview-serve");
136 }
137 _ => return Ok(false),
138 }
139 command
140 .stdin(std::process::Stdio::null())
141 .stdout(std::process::Stdio::null())
142 .stderr(std::process::Stdio::null());
143 #[cfg(unix)]
144 {
145 use std::os::unix::process::CommandExt;
146 command.process_group(0);
147 }
148 command
149 .spawn()
150 .map_err(|error| format!("start preview server: {error}"))?;
151 for _ in 0..20 {
152 tokio::time::sleep(Duration::from_millis(100)).await;
153 if matches!(ping(&self.config.base_url, 150).await, PingResult::Ok) {
154 return Ok(true);
155 }
156 }
157 Err("preview server did not become healthy after startup".into())
158 }
159
160 async fn ensure_topic(&self, pid: &str, topic_id: &str, title: &str) -> Result<(), String> {
161 let url = format!("{}/api/projects/{pid}/topics", self.config.base_url);
162 let body = serde_json::json!({
163 "id": topic_id,
164 "title": title,
165 });
166 let resp = match self.post_json(&url, &body).await {
167 ResolveOutcome::Ok(response) => response,
168 ResolveOutcome::Unavailable => return Err("topic server unavailable".into()),
169 ResolveOutcome::Fail(message) => return Err(format!("topic {message}")),
170 };
171 let status = resp.status();
172 if status.is_success() || status.as_u16() == 409 {
173 return Ok(());
174 }
175 Err(format!(
176 "topic http {status}: {}",
177 resp.text().await.unwrap_or_default()
178 ))
179 }
180}
181
182impl Tool for PreviewPush {
183 fn name(&self) -> &str {
184 "preview.push"
185 }
186
187 fn tier(&self) -> Tier {
188 Tier::One
189 }
190
191 fn description(&self) -> Option<&str> {
192 Some(
193 "Push markdown, mermaid, HTML, image, or diff content to the local preview server for browser review. Use it when the user asks to see a rendered artifact, diagram, diff, or audit page.",
194 )
195 }
196
197 fn input_schema(&self) -> serde_json::Value {
198 serde_json::json!({
199 "type": "object",
200 "properties": {
201 "topic": {"type": "string", "description": "Preview topic id to group related blocks."},
202 "title": {"type": "string", "description": "Human-readable topic title."},
203 "kind": {"type": "string", "enum": ["markdown", "mermaid", "html", "image", "diff"], "default": "markdown", "description": "Block type to render."},
204 "content": {"type": "string", "description": "Block content. Required for markdown, mermaid, and HTML; optional for image and diff when their specific fields are supplied."},
205 "image_base64": {"type": "string", "description": "Base64 image data for kind=image."},
206 "image_path": {"type": "string", "description": "Local image path for kind=image."},
207 "media_type": {"type": "string", "description": "Optional MIME type for image_base64, such as image/png."},
208 "raw_diff": {"type": "string", "description": "Raw patch text for kind=diff."},
209 "commit_sha": {"type": "string", "description": "Commit SHA for kind=diff commit mode. Requires repo_path."},
210 "repo_path": {"type": "string", "description": "Repository path for kind=diff commit mode. Requires commit_sha."}
211 },
212 "required": ["topic", "title"]
213 })
214 }
215
216 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
217 Box::pin(async move {
218 let topic = extract_string(&args, "topic", 0)?;
219 let title = extract_string(&args, "title", 1)?;
220 let kind = extract_optional_string(&args, "kind").unwrap_or_else(|| "markdown".into());
221 let content = match kind.as_str() {
222 "image" | "diff" => extract_optional_string(&args, "content").unwrap_or_default(),
223 _ => extract_string(&args, "content", 2)?,
224 };
225
226 if content.len() > self.config.max_body_bytes {
227 return Err(RuntimeError::ToolFailed(format!(
228 "preview.push: content {} bytes exceeds max {}",
229 content.len(),
230 self.config.max_body_bytes
231 )));
232 }
233
234 let pid = match self.ensure_project().await {
235 ResolveOutcome::Ok(id) => id,
236 ResolveOutcome::Unavailable => return Ok(unavailable()),
237 ResolveOutcome::Fail(msg) => {
238 return Err(RuntimeError::ToolFailed(format!("preview.push: {msg}")));
239 }
240 };
241
242 self.ensure_topic(&pid, &topic, &title)
243 .await
244 .map_err(|e| RuntimeError::ToolFailed(format!("preview.push: {e}")))?;
245
246 let block = build_block(&kind, &content, &args, self.config.max_body_bytes)?;
247 let url = format!(
248 "{}/api/projects/{pid}/topics/{topic}/blocks",
249 self.config.base_url
250 );
251 let resp = self.post_json(&url, &block).await;
252 match resp {
253 ResolveOutcome::Ok(r) if r.status().is_success() => {
254 let json: serde_json::Value = r.json().await.map_err(|e| {
255 RuntimeError::ToolFailed(format!("preview.push decode: {e}"))
256 })?;
257 let block_id = json
258 .get("id")
259 .and_then(|v| v.as_str())
260 .unwrap_or_default()
261 .to_string();
262 let preview_url = json
263 .get("rendered_html_preview_url")
264 .and_then(|v| v.as_str())
265 .unwrap_or_default()
266 .to_string();
267 Ok(Value::Struct(vec![
268 ("status".into(), Value::Str("ok".into())),
269 ("project_id".into(), Value::Str(pid)),
270 ("topic_id".into(), Value::Str(topic)),
271 ("block_id".into(), Value::Str(block_id)),
272 ("url".into(), Value::Str(preview_url)),
273 ]))
274 }
275 ResolveOutcome::Ok(r) => Err(RuntimeError::ToolFailed(format!(
276 "preview.push http {}: {}",
277 r.status(),
278 r.text().await.unwrap_or_default()
279 ))),
280 ResolveOutcome::Unavailable => Ok(unavailable()),
281 ResolveOutcome::Fail(message) => {
282 Err(RuntimeError::ToolFailed(format!("preview.push {message}")))
283 }
284 }
285 })
286 }
287}
288
289pub async fn ping(base_url: &str, timeout_ms: u64) -> PingResult {
290 let client = match reqwest::Client::builder()
291 .timeout(Duration::from_millis(timeout_ms))
292 .build()
293 {
294 Ok(c) => c,
295 Err(e) => return PingResult::Fail(format!("build client: {e}")),
296 };
297 match client.get(format!("{base_url}/api/health")).send().await {
298 Ok(r) if r.status().is_success() => PingResult::Ok,
299 Ok(r) => PingResult::Fail(format!("http {}", r.status())),
300 Err(e) if is_connection_refused(&e) => PingResult::Unavailable,
301 Err(e) => PingResult::Fail(format!("net: {e}")),
302 }
303}
304
305#[derive(Debug)]
306pub enum PingResult {
307 Ok,
308 Unavailable,
309 Fail(String),
310}
311
312enum ResolveOutcome<T> {
313 Ok(T),
314 Unavailable,
315 Fail(String),
316}
317
318fn unavailable() -> Value {
319 Value::Struct(vec![
320 ("status".into(), Value::Str("unavailable".into())),
321 (
322 "hint".into(),
323 Value::Str("preview server not reachable on configured base_url".into()),
324 ),
325 ])
326}
327
328fn is_connection_refused(e: &reqwest::Error) -> bool {
329 let msg = format!("{e}");
330 msg.contains("Connection refused")
331 || msg.contains("connection refused")
332 || msg.contains("tcp connect error")
333 || e.is_connect()
334}
335
336fn build_block(
337 kind: &str,
338 content: &str,
339 args: &ToolArgs,
340 max_body_bytes: usize,
341) -> Result<serde_json::Value, RuntimeError> {
342 Ok(match kind {
343 "markdown" => serde_json::json!({ "kind": "markdown", "content": content }),
344 "mermaid" => serde_json::json!({ "kind": "mermaid", "source": content }),
345 "html" => serde_json::json!({ "kind": "html", "fragment": content }),
346 "image" => build_image_block(args, max_body_bytes)?,
347 "diff" => build_diff_block(content, args)?,
348 other => {
349 return Err(RuntimeError::ToolFailed(format!(
350 "preview.push: unsupported kind `{other}` (want markdown | mermaid | html | image | diff)"
351 )));
352 }
353 })
354}
355
356fn build_image_block(
357 args: &ToolArgs,
358 max_body_bytes: usize,
359) -> Result<serde_json::Value, RuntimeError> {
360 let base64 = extract_optional_string(args, "image_base64");
361 let path = extract_optional_string(args, "image_path");
362 let (b64, media_type) = match (base64, path) {
363 (Some(b), _) => (b, extract_optional_string(args, "media_type")),
364 (None, Some(p)) => {
365 let bytes = std::fs::read(&p).map_err(|e| {
366 RuntimeError::ToolFailed(format!("preview.push image_path {p}: {e}"))
367 })?;
368 if bytes.len() > max_body_bytes {
369 return Err(RuntimeError::ToolFailed(format!(
370 "preview.push: image_path {} bytes exceeds max_body_bytes {max_body_bytes}; upload endpoint not implemented",
371 bytes.len()
372 )));
373 }
374 use base64::Engine;
375 let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
376 let media = guess_media_type(&p);
377 (encoded, Some(media))
378 }
379 _ => {
380 return Err(RuntimeError::ToolFailed(
381 "preview.push image: expect one of image_base64 or image_path".into(),
382 ));
383 }
384 };
385 if b64.len() > max_body_bytes {
386 return Err(RuntimeError::ToolFailed(format!(
387 "preview.push image: base64 {} bytes exceeds max_body_bytes {max_body_bytes}",
388 b64.len()
389 )));
390 }
391 let mut block = serde_json::json!({ "kind": "image", "image_base64": b64 });
392 if let Some(m) = media_type
393 && let Some(obj) = block.as_object_mut()
394 {
395 obj.insert("media_type".to_string(), serde_json::Value::String(m));
396 }
397 Ok(block)
398}
399
400fn build_diff_block(content: &str, args: &ToolArgs) -> Result<serde_json::Value, RuntimeError> {
401 let raw_diff = extract_optional_string(args, "raw_diff");
402 let commit_sha = extract_optional_string(args, "commit_sha");
403 let repo_path = extract_optional_string(args, "repo_path");
404 if let (Some(sha), Some(repo)) = (commit_sha.as_ref(), repo_path.as_ref()) {
405 return Ok(serde_json::json!({
406 "kind": "diff",
407 "mode": "commit_diff",
408 "commit_sha": sha,
409 "repo_path": repo,
410 }));
411 }
412 let patch = raw_diff.or_else(|| {
413 if content.is_empty() {
414 None
415 } else {
416 Some(content.to_string())
417 }
418 });
419 let Some(patch) = patch else {
420 return Err(RuntimeError::ToolFailed(
421 "preview.push diff: expect either (commit_sha + repo_path) or raw_diff / content"
422 .into(),
423 ));
424 };
425 Ok(serde_json::json!({
426 "kind": "diff",
427 "mode": "raw_diff",
428 "patch_text": patch,
429 }))
430}
431
432fn guess_media_type(path: &str) -> String {
433 let lower = path.to_ascii_lowercase();
434 if lower.ends_with(".png") {
435 "image/png".into()
436 } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
437 "image/jpeg".into()
438 } else if lower.ends_with(".gif") {
439 "image/gif".into()
440 } else if lower.ends_with(".webp") {
441 "image/webp".into()
442 } else {
443 "application/octet-stream".into()
444 }
445}
446
447fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
448 let value = match args.named(name) {
449 Some(v) => v,
450 None => args.positional(pos)?,
451 };
452 match value {
453 Value::Str(s) => Ok(s.clone()),
454 Value::Path(p) => Ok(p.display().to_string()),
455 other => Err(RuntimeError::TypeMismatch {
456 expected: "string or path".into(),
457 actual: other.kind_name().into(),
458 }),
459 }
460}
461
462fn extract_optional_string(args: &ToolArgs, name: &str) -> Option<String> {
463 match args.named(name)? {
464 Value::Str(s) => Some(s.clone()),
465 _ => None,
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use wiremock::matchers::{method, path};
473 use wiremock::{Mock, MockServer, ResponseTemplate};
474
475 fn cfg(base: String) -> PreviewConfig {
476 PreviewConfig {
477 base_url: base,
478 timeout_ms: 500,
479 project_abs_path: "/tmp/atman-test".into(),
480 project_hint_slug: Some("atman-test".into()),
481 max_body_bytes: 1_000_000,
482 }
483 }
484
485 #[tokio::test]
486 async fn push_markdown_block_registers_project_then_topic_then_block() {
487 let server = MockServer::start().await;
488 Mock::given(method("POST"))
489 .and(path("/api/projects"))
490 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
491 "id": "atman-test-abc",
492 "slug": "atman-test",
493 "id_source": "fallback_random",
494 "project_paths": [],
495 "agents_md_was_injected": false,
496 "url": "http://x",
497 })))
498 .expect(1)
499 .mount(&server)
500 .await;
501 Mock::given(method("POST"))
502 .and(path("/api/projects/atman-test-abc/topics"))
503 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
504 "id": "review-2026-07-03",
505 "url": "http://x",
506 "blocks_endpoint": "http://x",
507 "assets_endpoint": "http://x",
508 })))
509 .mount(&server)
510 .await;
511 Mock::given(method("POST"))
512 .and(path(
513 "/api/projects/atman-test-abc/topics/review-2026-07-03/blocks",
514 ))
515 .and(wiremock::matchers::body_partial_json(serde_json::json!({
516 "kind": "markdown",
517 "content": "# hello",
518 })))
519 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
520 "id": "blk_1",
521 "position": 0,
522 "rendered_html_preview_url": "http://x/blk1",
523 })))
524 .expect(1)
525 .mount(&server)
526 .await;
527
528 let tool = PreviewPush::new(cfg(server.uri()));
529 let ctx = ToolCtx::new();
530 let args = ToolArgs {
531 positional: vec![
532 Value::Str("review-2026-07-03".into()),
533 Value::Str("Review".into()),
534 Value::Str("# hello".into()),
535 ],
536 named: vec![],
537 };
538 let v = tool.call(args, &ctx).await.unwrap();
539 let Value::Struct(fields) = v else {
540 panic!("expected struct");
541 };
542 assert!(matches!(
543 &fields.iter().find(|(k, _)| k == "status").unwrap().1,
544 Value::Str(s) if s == "ok"
545 ));
546 }
547
548 #[tokio::test]
549 async fn push_returns_unavailable_on_connection_refused() {
550 let tool = PreviewPush::new(cfg("http://127.0.0.1:1".into()));
551 let ctx = ToolCtx::new();
552 let args = ToolArgs {
553 positional: vec![
554 Value::Str("t".into()),
555 Value::Str("T".into()),
556 Value::Str("c".into()),
557 ],
558 named: vec![],
559 };
560 let v = tool.call(args, &ctx).await.unwrap();
561 let Value::Struct(fields) = v else {
562 panic!("expected struct");
563 };
564 assert!(matches!(
565 &fields.iter().find(|(k, _)| k == "status").unwrap().1,
566 Value::Str(s) if s == "unavailable"
567 ));
568 }
569
570 #[tokio::test]
571 async fn push_treats_409_on_topic_as_idempotent_success() {
572 let server = MockServer::start().await;
573 Mock::given(method("POST"))
574 .and(path("/api/projects"))
575 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
576 "id": "p1",
577 "slug": "p",
578 "id_source": "fallback_random",
579 "project_paths": [],
580 "agents_md_was_injected": false,
581 "url": "http://x",
582 })))
583 .mount(&server)
584 .await;
585 Mock::given(method("POST"))
586 .and(path("/api/projects/p1/topics"))
587 .respond_with(ResponseTemplate::new(409).set_body_string("duplicate"))
588 .mount(&server)
589 .await;
590 Mock::given(method("POST"))
591 .and(path("/api/projects/p1/topics/t/blocks"))
592 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
593 "id": "b1",
594 "position": 1,
595 "rendered_html_preview_url": "http://x",
596 })))
597 .expect(1)
598 .mount(&server)
599 .await;
600
601 let tool = PreviewPush::new(cfg(server.uri()));
602 let ctx = ToolCtx::new();
603 let args = ToolArgs {
604 positional: vec![
605 Value::Str("t".into()),
606 Value::Str("T".into()),
607 Value::Str("c".into()),
608 ],
609 named: vec![],
610 };
611 let v = tool.call(args, &ctx).await.unwrap();
612 let Value::Struct(fields) = v else {
613 panic!("expected struct");
614 };
615 assert!(matches!(
616 &fields.iter().find(|(k, _)| k == "status").unwrap().1,
617 Value::Str(s) if s == "ok"
618 ));
619 }
620
621 #[tokio::test]
622 async fn push_rejects_unknown_block_kind() {
623 let server = MockServer::start().await;
624 Mock::given(method("POST"))
625 .and(path("/api/projects"))
626 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
627 "id": "p",
628 "slug": "p",
629 "id_source": "fallback_random",
630 "project_paths": [],
631 "agents_md_was_injected": false,
632 "url": "http://x",
633 })))
634 .mount(&server)
635 .await;
636 Mock::given(method("POST"))
637 .and(path("/api/projects/p/topics"))
638 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
639 "id": "t",
640 "url": "",
641 "blocks_endpoint": "",
642 "assets_endpoint": ""
643 })))
644 .mount(&server)
645 .await;
646 let tool = PreviewPush::new(cfg(server.uri()));
647 let ctx = ToolCtx::new();
648 let args = ToolArgs {
649 positional: vec![
650 Value::Str("t".into()),
651 Value::Str("T".into()),
652 Value::Str("c".into()),
653 ],
654 named: vec![("kind".into(), Value::Str("video".into()))],
655 };
656 let err = tool.call(args, &ctx).await.unwrap_err();
657 assert!(format!("{err}").contains("unsupported kind"));
658 }
659
660 async fn mount_project_and_topic(server: &MockServer) {
661 Mock::given(method("POST"))
662 .and(path("/api/projects"))
663 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
664 "id": "p",
665 "slug": "p",
666 "id_source": "fallback_random",
667 "project_paths": [],
668 "agents_md_was_injected": false,
669 "url": "http://x",
670 })))
671 .mount(server)
672 .await;
673 Mock::given(method("POST"))
674 .and(path("/api/projects/p/topics"))
675 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
676 "id": "t",
677 "url": "",
678 "blocks_endpoint": "",
679 "assets_endpoint": ""
680 })))
681 .mount(server)
682 .await;
683 }
684
685 #[tokio::test]
686 async fn push_image_base64_lands_as_image_block_with_media_type() {
687 let server = MockServer::start().await;
688 mount_project_and_topic(&server).await;
689 Mock::given(method("POST"))
690 .and(path("/api/projects/p/topics/t/blocks"))
691 .and(wiremock::matchers::body_partial_json(serde_json::json!({
692 "kind": "image",
693 "image_base64": "iVBOR",
694 "media_type": "image/png",
695 })))
696 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
697 "id": "b_img",
698 "position": 0,
699 "rendered_html_preview_url": "http://x/b_img",
700 })))
701 .expect(1)
702 .mount(&server)
703 .await;
704
705 let tool = PreviewPush::new(cfg(server.uri()));
706 let ctx = ToolCtx::new();
707 let args = ToolArgs {
708 positional: vec![Value::Str("t".into()), Value::Str("T".into())],
709 named: vec![
710 ("kind".into(), Value::Str("image".into())),
711 ("image_base64".into(), Value::Str("iVBOR".into())),
712 ("media_type".into(), Value::Str("image/png".into())),
713 ],
714 };
715 let v = tool.call(args, &ctx).await.unwrap();
716 let Value::Struct(fields) = v else {
717 panic!("expected struct");
718 };
719 assert!(matches!(
720 &fields.iter().find(|(k, _)| k == "status").unwrap().1,
721 Value::Str(s) if s == "ok"
722 ));
723 }
724
725 #[tokio::test]
726 async fn push_image_path_reads_bytes_and_encodes_base64() {
727 let server = MockServer::start().await;
728 mount_project_and_topic(&server).await;
729 let tmp = tempfile::NamedTempFile::with_suffix(".png").unwrap();
730 std::fs::write(tmp.path(), b"fake-png-bytes").unwrap();
731 Mock::given(method("POST"))
732 .and(path("/api/projects/p/topics/t/blocks"))
733 .and(wiremock::matchers::body_partial_json(serde_json::json!({
734 "kind": "image",
735 "media_type": "image/png",
736 })))
737 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
738 "id": "b_img_path",
739 "position": 0,
740 "rendered_html_preview_url": "http://x",
741 })))
742 .expect(1)
743 .mount(&server)
744 .await;
745
746 let tool = PreviewPush::new(cfg(server.uri()));
747 let ctx = ToolCtx::new();
748 let args = ToolArgs {
749 positional: vec![Value::Str("t".into()), Value::Str("T".into())],
750 named: vec![
751 ("kind".into(), Value::Str("image".into())),
752 (
753 "image_path".into(),
754 Value::Str(tmp.path().display().to_string()),
755 ),
756 ],
757 };
758 let v = tool.call(args, &ctx).await.unwrap();
759 let Value::Struct(fields) = v else {
760 panic!("expected struct");
761 };
762 assert!(matches!(
763 &fields.iter().find(|(k, _)| k == "status").unwrap().1,
764 Value::Str(s) if s == "ok"
765 ));
766 }
767
768 #[tokio::test]
769 async fn push_diff_raw_diff_carries_patch_text() {
770 let server = MockServer::start().await;
771 mount_project_and_topic(&server).await;
772 let patch = "--- a/foo\n+++ b/foo\n@@ -1 +1 @@\n-old\n+new\n";
773 Mock::given(method("POST"))
774 .and(path("/api/projects/p/topics/t/blocks"))
775 .and(wiremock::matchers::body_partial_json(serde_json::json!({
776 "kind": "diff",
777 "mode": "raw_diff",
778 "patch_text": patch,
779 })))
780 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
781 "id": "b_diff",
782 "position": 0,
783 "rendered_html_preview_url": "http://x/diff",
784 })))
785 .expect(1)
786 .mount(&server)
787 .await;
788
789 let tool = PreviewPush::new(cfg(server.uri()));
790 let ctx = ToolCtx::new();
791 let args = ToolArgs {
792 positional: vec![Value::Str("t".into()), Value::Str("T".into())],
793 named: vec![
794 ("kind".into(), Value::Str("diff".into())),
795 ("raw_diff".into(), Value::Str(patch.into())),
796 ],
797 };
798 let v = tool.call(args, &ctx).await.unwrap();
799 let Value::Struct(fields) = v else {
800 panic!("expected struct");
801 };
802 assert!(matches!(
803 &fields.iter().find(|(k, _)| k == "status").unwrap().1,
804 Value::Str(s) if s == "ok"
805 ));
806 }
807
808 #[tokio::test]
809 async fn push_diff_commit_sha_switches_mode() {
810 let server = MockServer::start().await;
811 mount_project_and_topic(&server).await;
812 Mock::given(method("POST"))
813 .and(path("/api/projects/p/topics/t/blocks"))
814 .and(wiremock::matchers::body_partial_json(serde_json::json!({
815 "kind": "diff",
816 "mode": "commit_diff",
817 "commit_sha": "abc123",
818 "repo_path": "/repo",
819 })))
820 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
821 "id": "b_diff_sha",
822 "position": 0,
823 "rendered_html_preview_url": "http://x/diff_sha",
824 })))
825 .expect(1)
826 .mount(&server)
827 .await;
828
829 let tool = PreviewPush::new(cfg(server.uri()));
830 let ctx = ToolCtx::new();
831 let args = ToolArgs {
832 positional: vec![Value::Str("t".into()), Value::Str("T".into())],
833 named: vec![
834 ("kind".into(), Value::Str("diff".into())),
835 ("commit_sha".into(), Value::Str("abc123".into())),
836 ("repo_path".into(), Value::Str("/repo".into())),
837 ],
838 };
839 let v = tool.call(args, &ctx).await.unwrap();
840 let Value::Struct(fields) = v else {
841 panic!("expected struct");
842 };
843 assert!(matches!(
844 &fields.iter().find(|(k, _)| k == "status").unwrap().1,
845 Value::Str(s) if s == "ok"
846 ));
847 }
848
849 #[tokio::test]
850 async fn push_image_without_data_returns_error() {
851 let tool = PreviewPush::new(cfg("http://127.0.0.1:1".into()));
852 let ctx = ToolCtx::new();
853 let args = ToolArgs {
854 positional: vec![Value::Str("t".into()), Value::Str("T".into())],
855 named: vec![("kind".into(), Value::Str("image".into()))],
856 };
857 let err = tool.call(args, &ctx).await;
858 match err {
859 Err(RuntimeError::ToolFailed(msg)) => {
860 assert!(
861 msg.contains("image_base64") || msg.contains("image_path"),
862 "err: {msg}"
863 );
864 }
865 Ok(Value::Struct(fields))
866 if matches!(
867 &fields.iter().find(|(k, _)| k == "status").unwrap().1,
868 Value::Str(s) if s == "unavailable"
869 ) => {}
870 other => panic!("expected image data error, got {other:?}"),
871 }
872 }
873
874 #[tokio::test]
875 async fn push_rejects_content_over_max_bytes() {
876 let mut c = cfg("http://127.0.0.1:1".into());
877 c.max_body_bytes = 10;
878 let tool = PreviewPush::new(c);
879 let ctx = ToolCtx::new();
880 let args = ToolArgs {
881 positional: vec![
882 Value::Str("t".into()),
883 Value::Str("T".into()),
884 Value::Str("x".repeat(100)),
885 ],
886 named: vec![],
887 };
888 let err = tool.call(args, &ctx).await.unwrap_err();
889 assert!(format!("{err}").contains("exceeds max"));
890 }
891
892 #[tokio::test]
893 async fn ping_returns_ok_on_healthy_server() {
894 let server = MockServer::start().await;
895 Mock::given(method("GET"))
896 .and(path("/api/health"))
897 .respond_with(ResponseTemplate::new(200).set_body_string("ok"))
898 .mount(&server)
899 .await;
900 assert!(matches!(ping(&server.uri(), 500).await, PingResult::Ok));
901 }
902
903 #[tokio::test]
904 async fn ping_returns_unavailable_on_connection_refused() {
905 assert!(matches!(
906 ping("http://127.0.0.1:1", 200).await,
907 PingResult::Unavailable
908 ));
909 }
910}