Skip to content

Commit 25c900d

Browse files
authored
Merge pull request #82 from LAA-Software-Engineering/feat/cli-diff-command
feat(cli): agentctl diff (desired vs applied detail)
2 parents be6d9d0 + 3dac3db commit 25c900d

8 files changed

Lines changed: 755 additions & 1 deletion

File tree

internal/cli/diff.go

Lines changed: 404 additions & 0 deletions
Large diffs are not rendered by default.

internal/cli/diff_test.go

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
package cli
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"testing"
12+
"time"
13+
14+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/apply"
15+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/plan"
16+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
17+
)
18+
19+
func TestDiff_firstPlan_threeCreates(t *testing.T) {
20+
root := t.TempDir()
21+
copyPlanFixture(t, root)
22+
db := filepath.Join(t.TempDir(), "diff1.db")
23+
24+
ResetGlobalsForTest()
25+
cmd := NewRootCmd()
26+
var out bytes.Buffer
27+
cmd.SetOut(&out)
28+
cmd.SetErr(&out)
29+
cmd.SetArgs([]string{"diff", "--project", root, "--state", db})
30+
if err := cmd.Execute(); err != nil {
31+
t.Fatal(err)
32+
}
33+
s := out.String()
34+
if !strings.Contains(s, "Project/plan-fixture (create)") {
35+
t.Fatalf("missing project create:\n%s", s)
36+
}
37+
if !strings.Contains(s, "Policy/default (create)") || !strings.Contains(s, "Tool/helper (create)") {
38+
t.Fatalf("missing policy/tool:\n%s", s)
39+
}
40+
if !strings.Contains(s, "Desired specification:") {
41+
t.Fatalf("missing desired block:\n%s", s)
42+
}
43+
}
44+
45+
func TestDiff_afterApply_noDifferences(t *testing.T) {
46+
ctx := context.Background()
47+
root := t.TempDir()
48+
copyPlanFixture(t, root)
49+
db := filepath.Join(t.TempDir(), "diff2.db")
50+
51+
g := &Global{ProjectRoot: root}
52+
graph, _, err := prepareProjectGraph(root, g)
53+
if err != nil {
54+
t.Fatal(err)
55+
}
56+
st, err := sqlite.Open(ctx, db)
57+
if err != nil {
58+
t.Fatal(err)
59+
}
60+
pl, err := plan.NewPlanner(st).ComputePlan(ctx, "local", graph)
61+
if err != nil {
62+
t.Fatal(err)
63+
}
64+
if err := apply.NewApplier(st).ApplyPlan(ctx, "local", graph, pl, time.Now().UTC()); err != nil {
65+
t.Fatal(err)
66+
}
67+
_ = st.Close()
68+
69+
ResetGlobalsForTest()
70+
cmd := NewRootCmd()
71+
var out bytes.Buffer
72+
cmd.SetOut(&out)
73+
cmd.SetErr(&out)
74+
cmd.SetArgs([]string{"diff", "--project", root, "--state", db})
75+
if err := cmd.Execute(); err != nil {
76+
t.Fatal(err)
77+
}
78+
if !strings.Contains(out.String(), "No differences between desired configuration and applied state.") {
79+
t.Fatalf("got:\n%s", out.String())
80+
}
81+
}
82+
83+
func TestDiff_singleResource_inSync(t *testing.T) {
84+
ctx := context.Background()
85+
root := t.TempDir()
86+
copyPlanFixture(t, root)
87+
db := filepath.Join(t.TempDir(), "diff3.db")
88+
89+
g := &Global{ProjectRoot: root}
90+
graph, _, err := prepareProjectGraph(root, g)
91+
if err != nil {
92+
t.Fatal(err)
93+
}
94+
st, err := sqlite.Open(ctx, db)
95+
if err != nil {
96+
t.Fatal(err)
97+
}
98+
pl, err := plan.NewPlanner(st).ComputePlan(ctx, "local", graph)
99+
if err != nil {
100+
t.Fatal(err)
101+
}
102+
if err := apply.NewApplier(st).ApplyPlan(ctx, "local", graph, pl, time.Now().UTC()); err != nil {
103+
t.Fatal(err)
104+
}
105+
_ = st.Close()
106+
107+
ResetGlobalsForTest()
108+
cmd := NewRootCmd()
109+
var out bytes.Buffer
110+
cmd.SetOut(&out)
111+
cmd.SetErr(&out)
112+
cmd.SetArgs([]string{"diff", "tool/helper", "--project", root, "--state", db})
113+
if err := cmd.Execute(); err != nil {
114+
t.Fatal(err)
115+
}
116+
s := out.String()
117+
if !strings.Contains(s, "No differences for Tool/helper") {
118+
t.Fatalf("got:\n%s", s)
119+
}
120+
}
121+
122+
func TestDiff_singleResource_policyUpdate(t *testing.T) {
123+
ctx := context.Background()
124+
root := t.TempDir()
125+
copyPlanFixture(t, root)
126+
db := filepath.Join(t.TempDir(), "diff4.db")
127+
128+
g := &Global{ProjectRoot: root}
129+
graph, _, err := prepareProjectGraph(root, g)
130+
if err != nil {
131+
t.Fatal(err)
132+
}
133+
st, err := sqlite.Open(ctx, db)
134+
if err != nil {
135+
t.Fatal(err)
136+
}
137+
pl0, err := plan.NewPlanner(st).ComputePlan(ctx, "local", graph)
138+
if err != nil {
139+
t.Fatal(err)
140+
}
141+
if err := apply.NewApplier(st).ApplyPlan(ctx, "local", graph, pl0, time.Now().UTC()); err != nil {
142+
t.Fatal(err)
143+
}
144+
_ = st.Close()
145+
146+
policyPath := filepath.Join(root, "policy.yaml")
147+
b, err := os.ReadFile(policyPath)
148+
if err != nil {
149+
t.Fatal(err)
150+
}
151+
updated := strings.Replace(string(b), "maxTotalCostUsd: 3", "maxTotalCostUsd: 10", 1)
152+
if err := os.WriteFile(policyPath, []byte(updated), 0o644); err != nil {
153+
t.Fatal(err)
154+
}
155+
156+
ResetGlobalsForTest()
157+
cmd := NewRootCmd()
158+
var out bytes.Buffer
159+
cmd.SetOut(&out)
160+
cmd.SetErr(&out)
161+
cmd.SetArgs([]string{"diff", "Policy/default", "--project", root, "--state", db})
162+
if err := cmd.Execute(); err != nil {
163+
t.Fatal(err)
164+
}
165+
s := out.String()
166+
if !strings.Contains(s, "Policy/default (update)") {
167+
t.Fatalf("got:\n%s", s)
168+
}
169+
if !strings.Contains(s, "maxTotalCostUsd") {
170+
t.Fatalf("expected field path in:\n%s", s)
171+
}
172+
if !strings.Contains(s, "Applied specification:") || !strings.Contains(s, "Desired specification:") {
173+
t.Fatalf("expected side context:\n%s", s)
174+
}
175+
}
176+
177+
func TestDiff_unknownResource_exit2(t *testing.T) {
178+
root := t.TempDir()
179+
copyPlanFixture(t, root)
180+
db := filepath.Join(t.TempDir(), "diff5.db")
181+
182+
ResetGlobalsForTest()
183+
cmd := NewRootCmd()
184+
cmd.SetOut(io.Discard)
185+
cmd.SetErr(io.Discard)
186+
cmd.SetArgs([]string{"diff", "Agent/missing", "--project", root, "--state", db})
187+
err := cmd.Execute()
188+
if err == nil {
189+
t.Fatal("expected error")
190+
}
191+
if ExitCodeOf(err) != ExitValidationError {
192+
t.Fatalf("code=%d err=%v", ExitCodeOf(err), err)
193+
}
194+
}
195+
196+
func TestDiff_badKindName_exit2(t *testing.T) {
197+
root := t.TempDir()
198+
copyPlanFixture(t, root)
199+
db := filepath.Join(t.TempDir(), "diff6.db")
200+
201+
ResetGlobalsForTest()
202+
cmd := NewRootCmd()
203+
cmd.SetOut(io.Discard)
204+
cmd.SetErr(io.Discard)
205+
cmd.SetArgs([]string{"diff", "NotAKind/foo", "--project", root, "--state", db})
206+
err := cmd.Execute()
207+
if err == nil {
208+
t.Fatal("expected error")
209+
}
210+
if ExitCodeOf(err) != ExitValidationError {
211+
t.Fatalf("code=%d err=%v", ExitCodeOf(err), err)
212+
}
213+
}
214+
215+
func TestDiff_tooManyArgs_exit2(t *testing.T) {
216+
root := t.TempDir()
217+
copyPlanFixture(t, root)
218+
db := filepath.Join(t.TempDir(), "diff7.db")
219+
220+
ResetGlobalsForTest()
221+
cmd := NewRootCmd()
222+
cmd.SetOut(io.Discard)
223+
cmd.SetErr(io.Discard)
224+
cmd.SetArgs([]string{"diff", "Policy/a", "Policy/b", "--project", root, "--state", db})
225+
err := cmd.Execute()
226+
if err == nil {
227+
t.Fatal("expected error")
228+
}
229+
if ExitCodeOf(err) != ExitValidationError {
230+
t.Fatalf("code=%d err=%v", ExitCodeOf(err), err)
231+
}
232+
}
233+
234+
func TestDiff_json_firstPlan(t *testing.T) {
235+
root := t.TempDir()
236+
copyPlanFixture(t, root)
237+
db := filepath.Join(t.TempDir(), "diff8.db")
238+
239+
ResetGlobalsForTest()
240+
cmd := NewRootCmd()
241+
var out bytes.Buffer
242+
cmd.SetOut(&out)
243+
cmd.SetErr(&out)
244+
cmd.SetArgs([]string{"diff", "-o", "json", "--project", root, "--state", db})
245+
if err := cmd.Execute(); err != nil {
246+
t.Fatal(err)
247+
}
248+
var m map[string]any
249+
if err := json.Unmarshal(out.Bytes(), &m); err != nil {
250+
t.Fatal(err)
251+
}
252+
sum, ok := m["summary"].(map[string]any)
253+
if !ok {
254+
t.Fatalf("summary: %v", m["summary"])
255+
}
256+
if int(sum["create"].(float64)) != 3 || int(sum["update"].(float64)) != 0 || int(sum["delete"].(float64)) != 0 {
257+
t.Fatalf("summary: %v", sum)
258+
}
259+
res, ok := m["resources"].([]any)
260+
if !ok || len(res) != 3 {
261+
t.Fatalf("resources: %v", m["resources"])
262+
}
263+
}
264+
265+
func TestDiff_json_inSyncSingleTarget(t *testing.T) {
266+
ctx := context.Background()
267+
root := t.TempDir()
268+
copyPlanFixture(t, root)
269+
db := filepath.Join(t.TempDir(), "diff9.db")
270+
271+
g := &Global{ProjectRoot: root}
272+
graph, _, err := prepareProjectGraph(root, g)
273+
if err != nil {
274+
t.Fatal(err)
275+
}
276+
st, err := sqlite.Open(ctx, db)
277+
if err != nil {
278+
t.Fatal(err)
279+
}
280+
pl, err := plan.NewPlanner(st).ComputePlan(ctx, "local", graph)
281+
if err != nil {
282+
t.Fatal(err)
283+
}
284+
if err := apply.NewApplier(st).ApplyPlan(ctx, "local", graph, pl, time.Now().UTC()); err != nil {
285+
t.Fatal(err)
286+
}
287+
_ = st.Close()
288+
289+
ResetGlobalsForTest()
290+
cmd := NewRootCmd()
291+
var out bytes.Buffer
292+
cmd.SetOut(&out)
293+
cmd.SetErr(&out)
294+
cmd.SetArgs([]string{"diff", "-o", "json", "Project/plan-fixture", "--project", root, "--state", db})
295+
if err := cmd.Execute(); err != nil {
296+
t.Fatal(err)
297+
}
298+
var m map[string]any
299+
if err := json.Unmarshal(out.Bytes(), &m); err != nil {
300+
t.Fatal(err)
301+
}
302+
if m["inSync"] != true {
303+
t.Fatalf("inSync: %v", m)
304+
}
305+
if m["atTarget"] != "Project/plan-fixture" {
306+
t.Fatalf("atTarget: %v", m)
307+
}
308+
}

internal/cli/doc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
// .agentic/state.db, or project.spec.state.dsn / --state) and prints a diff plus risk delta
1111
// via [plan.ComputePlan] and [plan.FormatPlan].
1212
//
13+
// The diff command uses the same comparison but prints a detailed per-resource view (field-level
14+
// updates, full JSON for creates/deletes) and supports an optional Kind/name argument (§10.2).
15+
//
1316
// The apply command runs the same preparation and planning, then prompts on a TTY (unless
1417
// --auto-approve or AGENTCTL_AUTO_APPROVE) and persists via [apply.Applier.ApplyPlan].
1518
//

internal/cli/root.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func NewRootCmd() *cobra.Command {
2222
root := &cobra.Command{
2323
Use: "agentctl",
2424
Short: "Declarative control plane for agent systems",
25-
Long: "agentctl validates, plans, applies, and runs declarative agent systems defined as YAML.",
25+
Long: "agentctl validates, plans, diffs, applies, and runs declarative agent systems defined as YAML.",
2626
SilenceErrors: true,
2727
Run: func(cmd *cobra.Command, args []string) {
2828
_ = cmd.Help()
@@ -36,6 +36,7 @@ func NewRootCmd() *cobra.Command {
3636
root.AddCommand(newInitCmd())
3737
root.AddCommand(newValidateCmd())
3838
root.AddCommand(newPlanCmd())
39+
root.AddCommand(newDiffCmd())
3940
root.AddCommand(newApplyCmd())
4041
root.AddCommand(newRunCmd())
4142
root.AddCommand(newLogsCmd())

internal/cli/root_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ func TestRootHelp_listsGlobalFlags(t *testing.T) {
1919
t.Fatal(err)
2020
}
2121
out := buf.String()
22+
if !strings.Contains(out, "diff") {
23+
t.Fatalf("help should mention diff subcommand:\n%s", out)
24+
}
2225
for _, flag := range []string{"--env", "-e", "--output", "-o", "--project", "--state", "--no-color"} {
2326
if !strings.Contains(out, flag) {
2427
t.Fatalf("help output missing %q\n%s", flag, out)

internal/plan/planner.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,19 @@ func sortOperations(ops []Operation) {
199199
return si < sj
200200
})
201201
}
202+
203+
// ListDesiredResourceIDs returns IDs for every resource in the normalized desired graph (same set as [Planner.ComputePlan]).
204+
func ListDesiredResourceIDs(g *spec.ProjectGraph) ([]spec.ResourceID, error) {
205+
if g == nil {
206+
return nil, errors.New("plan: nil project graph")
207+
}
208+
rows, err := desiredRows(g)
209+
if err != nil {
210+
return nil, err
211+
}
212+
out := make([]spec.ResourceID, len(rows))
213+
for i, r := range rows {
214+
out[i] = r.id
215+
}
216+
return out, nil
217+
}

internal/plan/planner_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ func appliedFromDesired(t *testing.T, env string, g *spec.ProjectGraph) []state.
8282
return out
8383
}
8484

85+
func TestListDesiredResourceIDs_minimalGraph(t *testing.T) {
86+
g := minimalGraph()
87+
ids, err := ListDesiredResourceIDs(g)
88+
if err != nil {
89+
t.Fatal(err)
90+
}
91+
if len(ids) != 1 || ids[0].Kind != spec.KindProject || ids[0].Name != "acme" {
92+
t.Fatalf("%+v", ids)
93+
}
94+
}
95+
8596
func TestComputePlan_emptyStore_allCreate(t *testing.T) {
8697
g := minimalGraph()
8798
p := NewPlanner(&fakeDeploy{list: nil})

test/integration/cli_flow_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ func TestCLI_ExampleMVPFlow(t *testing.T) {
9191
t.Fatalf("expected empty plan:\n%s", out)
9292
}
9393

94+
out, err = runCLI(t, "diff", "--project", projDir, "--state", db)
95+
if err != nil {
96+
t.Fatalf("diff: %v\n%s", err, out)
97+
}
98+
if !strings.Contains(out, "No differences between desired configuration and applied state.") {
99+
t.Fatalf("diff after apply:\n%s", out)
100+
}
101+
94102
out, err = runCLI(t, "run", "workflow/hello", "--project", projDir, "--state", db)
95103
if err != nil {
96104
t.Fatalf("run: %v\n%s", err, out)

0 commit comments

Comments
 (0)