@@ -99,6 +99,81 @@ pub async fn update(cfg: &Config, monitor_id: i64, file: &str) -> Result<()> {
9999 formatter:: output ( cfg, & resp)
100100}
101101
102+ pub async fn diff (
103+ cfg : & Config ,
104+ monitor_id : i64 ,
105+ file : & str ,
106+ only : & [ String ] ,
107+ ignore : & [ String ] ,
108+ ) -> Result < ( ) > {
109+ // Read the candidate (desired-state) definition as a raw JSON value.
110+ // Using Value (rather than MonitorUpdateRequest) preserves the user's exact
111+ // field set and avoids SDK defaults overwriting omitted optional fields during
112+ // deserialization, which would produce spurious "modified" entries.
113+ // Trade-off: field-name typos in the candidate file won't be caught here;
114+ // they will appear as added/removed pairs in the diff output, which is still
115+ // actionable. `pup monitors update` will fail or no-op on unknown fields.
116+ let mut candidate: serde_json:: Value = util:: read_json_file ( file) ?;
117+
118+ // Fetch the live monitor
119+ let api = crate :: make_api!( MonitorsAPI , cfg) ;
120+ let live = api
121+ . get_monitor ( monitor_id, GetMonitorOptionalParams :: default ( ) )
122+ . await
123+ . map_err ( |e| anyhow:: anyhow!( "failed to get monitor: {:?}" , e) ) ?;
124+ let mut live = serde_json:: to_value ( & live)
125+ . map_err ( |e| anyhow:: anyhow!( "failed to serialize monitor {monitor_id} for diff: {e:?}" ) ) ?;
126+
127+ // Normalize both sides: strip server-managed read-only fields and drop nulls
128+ // so absent and explicit-null compare equal.
129+ // Note: the API may populate concrete option defaults (e.g. new_host_delay,
130+ // notify_no_data) in the live response that the candidate omits. Those appear
131+ // as "removed" because the candidate is treated as the complete desired state.
132+ // Use --ignore to suppress specific option fields if the noise is unwanted.
133+ util:: normalize_for_diff ( & mut live, util:: READONLY_MONITOR_FIELDS ) ;
134+ util:: normalize_for_diff ( & mut candidate, util:: READONLY_MONITOR_FIELDS ) ;
135+
136+ let entries = util:: scope_diff ( util:: diff_json ( & live, & candidate) , only, ignore) ;
137+
138+ // `update` (PUT) is a partial/merge update: fields absent from the candidate
139+ // file are left unchanged on the live monitor, not deleted. "removed" entries
140+ // in this diff show what the candidate does not specify — they will NOT be
141+ // removed by `pup monitors update`. Run `update` only for "added"/"modified"
142+ // changes; "removed" entries require no action unless you want to add those
143+ // fields to the candidate explicitly.
144+ let has_removed = entries
145+ . iter ( )
146+ . any ( |e| e. change == util:: ChangeKind :: Removed ) ;
147+ let next_action = if entries. is_empty ( ) {
148+ None
149+ } else if has_removed {
150+ Some (
151+ "review changes — note: 'removed' entries will NOT be deleted by \
152+ `pup monitors update` (partial update)"
153+ . to_string ( ) ,
154+ )
155+ } else {
156+ Some ( "review changes, then run `pup monitors update`" . to_string ( ) )
157+ } ;
158+ let meta = Metadata {
159+ count : Some ( entries. len ( ) ) ,
160+ truncated : false ,
161+ command : Some ( "monitors diff" . to_string ( ) ) ,
162+ next_action,
163+ } ;
164+ formatter:: format_and_print (
165+ & entries,
166+ & cfg. output_format ,
167+ cfg. agent_mode ,
168+ Some ( & meta) ,
169+ cfg. jq . as_deref ( ) ,
170+ ) ?;
171+ if entries. is_empty ( ) && !cfg. agent_mode {
172+ eprintln ! ( "No changes — monitor {monitor_id} is in sync." ) ;
173+ }
174+ Ok ( ( ) )
175+ }
176+
102177pub async fn search (
103178 cfg : & Config ,
104179 query : Option < String > ,
@@ -230,4 +305,198 @@ mod tests {
230305 assert ! ( result. is_ok( ) , "monitors delete failed: {:?}" , result. err( ) ) ;
231306 cleanup_env ( ) ;
232307 }
308+
309+ // ---- monitors diff ----
310+
311+ /// Write a temp file with the given JSON content; returns the path.
312+ /// Caller must delete the file when done.
313+ fn write_temp_json ( label : & str , content : & str ) -> String {
314+ let path =
315+ std:: env:: temp_dir ( ) . join ( format ! ( "pup_test_{label}_{}.json" , std:: process:: id( ) ) ) ;
316+ std:: fs:: write ( & path, content) . expect ( "write temp file" ) ;
317+ path. to_string_lossy ( ) . to_string ( )
318+ }
319+
320+ #[ tokio:: test]
321+ async fn test_monitors_diff_detects_changes ( ) {
322+ let _lock = lock_env ( ) . await ;
323+ let mut server = mockito:: Server :: new_async ( ) . await ;
324+ let cfg = test_config ( & server. url ( ) ) ;
325+
326+ // Live monitor returned by the API
327+ let live_body = r#"{
328+ "id": 12345,
329+ "name": "CPU Monitor",
330+ "type": "metric alert",
331+ "query": "avg(last_5m):avg:system.cpu.user{*} > 90",
332+ "message": "CPU high",
333+ "tags": ["env:prod"],
334+ "options": {"thresholds": {"critical": 90.0}}
335+ }"# ;
336+ let _mock = mock_any ( & mut server, "GET" , live_body) . await ;
337+
338+ // Candidate raises the threshold
339+ let candidate = r#"{
340+ "name": "CPU Monitor",
341+ "type": "metric alert",
342+ "query": "avg(last_5m):avg:system.cpu.user{*} > 95",
343+ "message": "CPU high",
344+ "tags": ["env:prod"],
345+ "options": {"thresholds": {"critical": 95.0}}
346+ }"# ;
347+ let path = write_temp_json ( "diff_detects_changes" , candidate) ;
348+
349+ let result = super :: diff ( & cfg, 12345 , & path, & [ ] , & [ ] ) . await ;
350+ let _ = std:: fs:: remove_file ( & path) ;
351+ assert ! ( result. is_ok( ) , "monitors diff failed: {:?}" , result. err( ) ) ;
352+ cleanup_env ( ) ;
353+ }
354+
355+ #[ tokio:: test]
356+ async fn test_monitors_diff_no_changes ( ) {
357+ let _lock = lock_env ( ) . await ;
358+ let mut server = mockito:: Server :: new_async ( ) . await ;
359+ let cfg = test_config ( & server. url ( ) ) ;
360+
361+ let live_body = r#"{
362+ "id": 12345,
363+ "name": "CPU Monitor",
364+ "type": "metric alert",
365+ "query": "avg(last_5m):avg:system.cpu.user{*} > 90",
366+ "message": "CPU high",
367+ "tags": ["env:prod"],
368+ "options": {"thresholds": {"critical": 90.0}}
369+ }"# ;
370+ let _mock = mock_any ( & mut server, "GET" , live_body) . await ;
371+
372+ // Candidate is identical (minus read-only `id`)
373+ let candidate = r#"{
374+ "name": "CPU Monitor",
375+ "type": "metric alert",
376+ "query": "avg(last_5m):avg:system.cpu.user{*} > 90",
377+ "message": "CPU high",
378+ "tags": ["env:prod"],
379+ "options": {"thresholds": {"critical": 90.0}}
380+ }"# ;
381+ let path = write_temp_json ( "diff_no_changes" , candidate) ;
382+
383+ let result = super :: diff ( & cfg, 12345 , & path, & [ ] , & [ ] ) . await ;
384+ let _ = std:: fs:: remove_file ( & path) ;
385+ assert ! (
386+ result. is_ok( ) ,
387+ "monitors diff (no-changes) failed: {:?}" ,
388+ result. err( )
389+ ) ;
390+ cleanup_env ( ) ;
391+ }
392+
393+ #[ tokio:: test]
394+ async fn test_monitors_diff_readonly_field_ignored ( ) {
395+ let _lock = lock_env ( ) . await ;
396+ let mut server = mockito:: Server :: new_async ( ) . await ;
397+ let cfg = test_config ( & server. url ( ) ) ;
398+
399+ let live_body = r#"{
400+ "id": 12345,
401+ "name": "CPU Monitor",
402+ "type": "metric alert",
403+ "query": "avg(last_5m):avg:system.cpu.user{*} > 90",
404+ "message": "CPU high",
405+ "overall_state": "OK",
406+ "creator": {"email": "someone@example.com"}
407+ }"# ;
408+ let _mock = mock_any ( & mut server, "GET" , live_body) . await ;
409+
410+ // Candidate omits `id`, `overall_state`, `creator` — should produce no diff
411+ let candidate = r#"{
412+ "name": "CPU Monitor",
413+ "type": "metric alert",
414+ "query": "avg(last_5m):avg:system.cpu.user{*} > 90",
415+ "message": "CPU high"
416+ }"# ;
417+ let path = write_temp_json ( "diff_readonly" , candidate) ;
418+
419+ let result = super :: diff ( & cfg, 12345 , & path, & [ ] , & [ ] ) . await ;
420+ let _ = std:: fs:: remove_file ( & path) ;
421+ assert ! (
422+ result. is_ok( ) ,
423+ "monitors diff (readonly) failed: {:?}" ,
424+ result. err( )
425+ ) ;
426+ cleanup_env ( ) ;
427+ }
428+
429+ #[ tokio:: test]
430+ async fn test_monitors_diff_file_not_found ( ) {
431+ let cfg = test_config ( "http://unused.local" ) ;
432+ let result = super :: diff ( & cfg, 12345 , "/nonexistent/path.json" , & [ ] , & [ ] ) . await ;
433+ assert ! ( result. is_err( ) ) ;
434+ assert ! ( result
435+ . unwrap_err( )
436+ . to_string( )
437+ . contains( "failed to read file" ) ) ;
438+ }
439+
440+ #[ tokio:: test]
441+ async fn test_monitors_diff_invalid_json ( ) {
442+ let path = write_temp_json ( "diff_invalid_json" , "not valid json {{{" ) ;
443+ let cfg = test_config ( "http://unused.local" ) ;
444+ let result = super :: diff ( & cfg, 12345 , & path, & [ ] , & [ ] ) . await ;
445+ let _ = std:: fs:: remove_file ( & path) ;
446+ assert ! ( result. is_err( ) ) ;
447+ assert ! ( result
448+ . unwrap_err( )
449+ . to_string( )
450+ . contains( "failed to parse JSON" ) ) ;
451+ }
452+
453+ #[ tokio:: test]
454+ async fn test_monitors_diff_fetch_failure ( ) {
455+ let _lock = lock_env ( ) . await ;
456+ let mut server = mockito:: Server :: new_async ( ) . await ;
457+ let cfg = test_config ( & server. url ( ) ) ;
458+ let _mock = mock_any ( & mut server, "GET" , r#"{"errors": ["Not Found"]}"# ) . await ;
459+
460+ let path = write_temp_json ( "diff_fetch_failure" , r#"{"name": "CPU Monitor"}"# ) ;
461+ // The mock returns a 200 with error body; the client may or may not error —
462+ // either outcome is acceptable as long as it doesn't panic.
463+ let _result = super :: diff ( & cfg, 99999 , & path, & [ ] , & [ ] ) . await ;
464+ let _ = std:: fs:: remove_file ( & path) ;
465+ cleanup_env ( ) ;
466+ }
467+
468+ #[ tokio:: test]
469+ async fn test_monitors_diff_with_only_flag ( ) {
470+ let _lock = lock_env ( ) . await ;
471+ let mut server = mockito:: Server :: new_async ( ) . await ;
472+ let cfg = test_config ( & server. url ( ) ) ;
473+
474+ let live_body = r#"{
475+ "id": 1,
476+ "name": "Old Name",
477+ "type": "metric alert",
478+ "query": "avg(last_5m):avg:system.cpu.user{*} > 90",
479+ "options": {"thresholds": {"critical": 90.0}}
480+ }"# ;
481+ let _mock = mock_any ( & mut server, "GET" , live_body) . await ;
482+
483+ // Candidate changes both name and threshold; --only options.thresholds should
484+ // scope the result (the diff command itself still succeeds)
485+ let candidate = r#"{
486+ "name": "New Name",
487+ "query": "avg(last_5m):avg:system.cpu.user{*} > 90",
488+ "options": {"thresholds": {"critical": 95.0}}
489+ }"# ;
490+ let path = write_temp_json ( "diff_only_flag" , candidate) ;
491+ let only = vec ! [ "options.thresholds" . to_string( ) ] ;
492+
493+ let result = super :: diff ( & cfg, 1 , & path, & only, & [ ] ) . await ;
494+ let _ = std:: fs:: remove_file ( & path) ;
495+ assert ! (
496+ result. is_ok( ) ,
497+ "monitors diff --only failed: {:?}" ,
498+ result. err( )
499+ ) ;
500+ cleanup_env ( ) ;
501+ }
233502}
0 commit comments