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