Skip to main content

alembic_engine/
plan_view.rs

1//! human-readable, plan-framed rendering of a plan's operations (create /
2//! update / delete), for the `plan` command's default output. this is the
3//! "see before write" view: it reads the plan only and never writes.
4//!
5//! this is distinct from [`crate::DriftReport`], which presents the same plan
6//! as drift (changed / missing / extra, desired-vs-observed). here the framing
7//! is the operations apply will perform.
8
9use crate::types::{Op, Plan};
10use alembic_core::key_string;
11use std::fmt::Write;
12
13/// how many operations to list per category before truncating the tail.
14const MAX_LISTED: usize = 50;
15
16/// render a plan's operations as a human-readable summary: a one-line count
17/// header, then each op grouped under create / update / delete with its type and
18/// human key (and per-field `from -> to` for updates). long categories are
19/// truncated with an `... and N more` line so a large plan stays readable.
20pub fn render_plan(plan: &Plan) -> String {
21    let mut create = Vec::new();
22    let mut update = Vec::new();
23    let mut delete = Vec::new();
24    for op in &plan.ops {
25        match op {
26            Op::Create {
27                type_name, desired, ..
28            } => create.push(format!("  {} {}", type_name, key_string(&desired.key))),
29            Op::Update {
30                type_name,
31                desired,
32                changes,
33                ..
34            } => {
35                let mut line = format!("  {} {}", type_name, key_string(&desired.key));
36                for change in changes {
37                    let _ = write!(
38                        line,
39                        "\n    {}: {} -> {}",
40                        change.field, change.from, change.to
41                    );
42                }
43                update.push(line);
44            }
45            Op::Delete { type_name, key, .. } => {
46                delete.push(format!("  {} {}", type_name, key_string(key)))
47            }
48        }
49    }
50
51    let mut out = format!(
52        "plan: {} to create, {} to update, {} to delete",
53        create.len(),
54        update.len(),
55        delete.len()
56    );
57    for (label, lines) in [
58        ("create", &create),
59        ("update", &update),
60        ("delete", &delete),
61    ] {
62        if lines.is_empty() {
63            continue;
64        }
65        let _ = write!(out, "\n\n{label}:");
66        for line in lines.iter().take(MAX_LISTED) {
67            let _ = write!(out, "\n{line}");
68        }
69        if lines.len() > MAX_LISTED {
70            let _ = write!(out, "\n  ... and {} more", lines.len() - MAX_LISTED);
71        }
72    }
73    out
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use crate::types::{FieldChange, Op, Plan};
80    use alembic_core::{Key, Object, Schema, TypeName, Uid};
81    use std::collections::BTreeMap;
82
83    fn key(v: &str) -> Key {
84        Key::from(BTreeMap::from([(
85            "name".to_string(),
86            serde_json::Value::String(v.to_string()),
87        )]))
88    }
89
90    fn object(uid: u128, type_name: &str, k: &str) -> Object {
91        Object::new(
92            Uid::from_u128(uid),
93            TypeName::new(type_name),
94            key(k),
95            Default::default(),
96        )
97        .unwrap()
98    }
99
100    fn plan_of(ops: Vec<Op>) -> Plan {
101        Plan {
102            schema: Schema {
103                types: BTreeMap::new(),
104            },
105            ops,
106            summary: None,
107            schema_preview: None,
108        }
109    }
110
111    #[test]
112    fn renders_each_category_with_keys_and_field_changes() {
113        let plan = plan_of(vec![
114            Op::Create {
115                uid: Uid::from_u128(1),
116                type_name: TypeName::new("dcim.device"),
117                desired: object(1, "dcim.device", "leaf01"),
118            },
119            Op::Update {
120                uid: Uid::from_u128(2),
121                type_name: TypeName::new("dcim.interface"),
122                desired: object(2, "dcim.interface", "eth0"),
123                changes: vec![FieldChange {
124                    field: "type".to_string(),
125                    from: serde_json::json!("access"),
126                    to: serde_json::json!("trunk"),
127                }],
128                backend_id: None,
129            },
130        ]);
131        let out = render_plan(&plan);
132        assert!(
133            out.starts_with("plan: 1 to create, 1 to update, 0 to delete"),
134            "{out}"
135        );
136        assert!(out.contains("create:"));
137        assert!(out.contains("dcim.device"));
138        assert!(out.contains("update:"));
139        assert!(out.contains(r#"type: "access" -> "trunk""#), "{out}");
140        assert!(!out.contains("delete:"));
141    }
142
143    #[test]
144    fn truncates_a_long_category() {
145        let ops = (0..MAX_LISTED as u128 + 5)
146            .map(|i| Op::Create {
147                uid: Uid::from_u128(i),
148                type_name: TypeName::new("dcim.device"),
149                desired: object(i, "dcim.device", &format!("d{i}")),
150            })
151            .collect();
152        let out = render_plan(&plan_of(ops));
153        assert!(out.contains("... and 5 more"), "{out}");
154    }
155}