agpm-cli 0.4.14

AGent Package Manager - A Git-based package manager for coding agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
//! Resource dependency types and implementations.
//!
//! This module provides the core dependency specification types used in AGPM manifests:
//! - `ResourceDependency`: Enum supporting both simple path-only and detailed specifications
//! - `DetailedDependency`: Full dependency specification with all configuration options

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

use crate::manifest::dependency_spec::DependencySpec;

/// A resource dependency specification supporting multiple formats.
///
/// Dependencies can be specified in two main formats to balance simplicity
/// with flexibility. The enum uses Serde's `untagged` attribute to automatically
/// deserialize the correct variant based on the TOML structure.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResourceDependency {
    /// Simple path-only dependency, typically for local files.
    ///
    /// This variant represents the simplest dependency format where only
    /// a file path is specified. It's primarily used for local dependencies
    /// that exist in the filesystem relative to the project.
    ///
    /// # Format
    ///
    /// ```toml
    /// dependency-name = "path/to/file.md"
    /// ```
    ///
    /// # Examples
    ///
    /// ```toml
    /// [agents]
    /// # Relative paths from manifest directory
    /// helper = "../shared/helper.md"
    /// custom = "./local/custom.md"
    ///
    /// # Absolute paths (not recommended)
    /// system = "/usr/local/share/agent.md"
    /// ```
    ///
    /// # Limitations
    ///
    /// - Cannot specify version constraints
    /// - Cannot reference remote Git sources
    /// - Must be a valid filesystem path
    /// - Path must exist at installation time
    Simple(String),

    /// Detailed dependency specification with full control.
    ///
    /// This variant provides complete control over dependency specification,
    /// supporting both local and remote dependencies with version constraints,
    /// Git references, and explicit source mapping.
    ///
    /// See [`DetailedDependency`] for field-level documentation.
    ///
    /// Note: This variant is boxed to reduce the overall size of the enum.
    Detailed(Box<DetailedDependency>),
}

/// Detailed dependency specification with full control over source resolution.
///
/// This struct provides fine-grained control over dependency specification,
/// supporting both local filesystem paths and remote Git repository resources
/// with flexible version constraints and Git reference handling.
///
/// # Field Relationships
///
/// The fields work together with specific validation rules:
/// - If `source` is specified: Must have either `version` or `git`
/// - If `source` is omitted: Dependency is local, `version` and `git` are ignored
/// - `path` is always required and cannot be empty
///
/// # Examples
///
/// ## Remote Dependencies
///
/// ```toml
/// [agents]
/// # Semantic version constraint
/// stable = { source = "official", path = "agents/stable.md", version = "v1.0.0" }
///
/// # Latest version (not recommended for production)
/// latest = { source = "community", path = "agents/utils.md", version = "latest" }
///
/// # Specific Git branch
/// cutting-edge = { source = "official", path = "agents/new.md", git = "develop" }
///
/// # Specific commit SHA (maximum reproducibility)
/// pinned = { source = "community", path = "agents/tool.md", git = "a1b2c3d4e5f6..." }
///
/// # Git tag
/// release = { source = "official", path = "agents/release.md", git = "v2.0-release" }
/// ```
///
/// ## Local Dependencies
///
/// ```toml
/// [agents]
/// # Local file (version/git fields ignored if present)
/// local-helper = { path = "../shared/helper.md" }
/// custom = { path = "./local/custom.md" }
/// ```
///
/// # Version Resolution Priority
///
/// When both `version` and `git` are specified, `git` takes precedence:
///
/// ```toml
/// # This will use the "develop" branch, not "v1.0.0"
/// conflicted = { source = "repo", path = "file.md", version = "v1.0.0", git = "develop" }
/// ```
///
/// # Path Format
///
/// Paths are interpreted differently based on context:
/// - **Remote dependencies**: Path within the Git repository
/// - **Local dependencies**: Filesystem path relative to manifest directory
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetailedDependency {
    /// Source repository name referencing the `[sources]` section.
    ///
    /// When specified, this dependency will be resolved from a Git repository.
    /// The name must exactly match a key in the manifest's `[sources]` table.
    ///
    /// **Omit this field** to create a local filesystem dependency.
    ///
    /// # Examples
    ///
    /// ```toml
    /// # References this source definition:
    /// [sources]
    /// official = "https://github.com/org/repo.git"
    ///
    /// [agents]
    /// remote-agent = { source = "official", path = "agents/tool.md", version = "v1.0.0" }
    /// local-agent = { path = "../local/tool.md" }  # No source = local dependency
    /// ```
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,

    /// Path to the resource file or glob pattern for multiple resources.
    ///
    /// For **remote dependencies**: Path within the Git repository\
    /// For **local dependencies**: Filesystem path relative to manifest directory\
    /// For **pattern dependencies**: Glob pattern to match multiple resources
    ///
    /// This field supports both individual file paths and glob patterns:
    /// - Individual file: `"agents/helper.md"`
    /// - Pattern matching: `"agents/*.md"`, `"**/*.md"`, `"agents/[a-z]*.md"`
    ///
    /// Pattern dependencies are detected by the presence of glob characters
    /// (`*`, `?`, `[`) in the path. When a pattern is detected, AGPM will
    /// expand it to match all resources in the source repository.
    ///
    /// # Examples
    ///
    /// ```toml
    /// # Remote: single file in git repo
    /// remote = { source = "repo", path = "agents/helper.md", version = "v1.0.0" }
    ///
    /// # Local: filesystem path
    /// local = { path = "../shared/helper.md" }
    ///
    /// # Pattern: all agents in AI folder
    /// ai_agents = { source = "repo", path = "agents/ai/*.md", version = "v1.0.0" }
    ///
    /// # Pattern: all agents recursively
    /// all_agents = { source = "repo", path = "agents/**/*.md", version = "v1.0.0" }
    /// ```
    pub path: String,

    /// Version constraint for Git tag resolution.
    ///
    /// Specifies which version of the resource to use when resolving from
    /// a Git repository. This field is ignored for local dependencies.
    ///
    /// **Note**: If both `version` and `git` are specified, `git` takes precedence.
    ///
    /// # Supported Formats
    ///
    /// - `"v1.0.0"` - Exact semantic version tag
    /// - `"1.0.0"` - Exact version (v prefix optional)
    /// - `"^1.0.0"` - Semantic version constraint (highest compatible 1.x.x)
    /// - `"latest"` - Git tag or branch named "latest" (not special - just a name)
    /// - `"main"` - Use main/master branch HEAD
    ///
    /// # Examples
    ///
    /// ```toml
    /// [agents]
    /// stable = { source = "repo", path = "agent.md", version = "v1.0.0" }
    /// flexible = { source = "repo", path = "agent.md", version = "^1.0.0" }
    /// latest-tag = { source = "repo", path = "agent.md", version = "latest" }  # If repo has a "latest" tag
    /// main = { source = "repo", path = "agent.md", version = "main" }
    /// ```
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,

    /// Git branch to track.
    ///
    /// Specifies a Git branch to use when resolving the dependency.
    /// Branch references are mutable and will update to the latest commit on each update.
    /// This field is ignored for local dependencies.
    ///
    /// # Examples
    ///
    /// ```toml
    /// [agents]
    /// # Track the main branch
    /// dev = { source = "repo", path = "agent.md", branch = "main" }
    ///
    /// # Track a feature branch
    /// experimental = { source = "repo", path = "agent.md", branch = "feature/new-capability" }
    /// ```
    #[serde(skip_serializing_if = "Option::is_none")]
    pub branch: Option<String>,

    /// Git commit hash (revision).
    ///
    /// Specifies an exact Git commit to use when resolving the dependency.
    /// Provides maximum reproducibility as commits are immutable.
    /// This field is ignored for local dependencies.
    ///
    /// # Examples
    ///
    /// ```toml
    /// [agents]
    /// # Pin to exact commit (full hash)
    /// pinned = { source = "repo", path = "agent.md", rev = "a1b2c3d4e5f67890abcdef1234567890abcdef12" }
    ///
    /// # Pin to exact commit (abbreviated)
    /// stable = { source = "repo", path = "agent.md", rev = "abc123def" }
    /// ```
    #[serde(skip_serializing_if = "Option::is_none")]
    pub rev: Option<String>,

    /// Command to execute for MCP servers.
    ///
    /// This field is specific to MCP server dependencies and specifies
    /// the command that will be executed to run the MCP server.
    /// Only used for entries in the `[mcp-servers]` section.
    ///
    /// # Examples
    ///
    /// ```toml
    /// [mcp-servers]
    /// github = { source = "repo", path = "mcp/github.toml", version = "v1.0.0", command = "npx" }
    /// sqlite = { path = "./local/sqlite.toml", command = "uvx" }
    /// ```
    #[serde(skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,

    /// Arguments to pass to the MCP server command.
    ///
    /// This field is specific to MCP server dependencies and provides
    /// the arguments that will be passed to the command when starting
    /// the MCP server. Only used for entries in the `[mcp-servers]` section.
    ///
    /// # Examples
    ///
    /// ```toml
    /// [mcp-servers]
    /// github = {
    ///     source = "repo",
    ///     path = "mcp/github.toml",
    ///     version = "v1.0.0",
    ///     command = "npx",
    ///     args = ["-y", "@modelcontextprotocol/server-github"]
    /// }
    /// sqlite = {
    ///     path = "./local/sqlite.toml",
    ///     command = "uvx",
    ///     args = ["mcp-server-sqlite", "--db", "./data/local.db"]
    /// }
    /// ```
    #[serde(skip_serializing_if = "Option::is_none")]
    pub args: Option<Vec<String>>,
    /// Custom target directory for this dependency.
    ///
    /// Overrides the default installation directory for this specific dependency.
    /// The path is relative to the `.claude` directory for consistency and security.
    /// If not specified, the dependency will be installed to the default location
    /// based on its resource type.
    ///
    /// # Examples
    ///
    /// ```toml
    /// [agents]
    /// # Install to .claude/custom/tools/ instead of default .claude/agents/
    /// special-agent = {
    ///     source = "repo",
    ///     path = "agent.md",
    ///     version = "v1.0.0",
    ///     target = "custom/tools"
    /// }
    ///
    /// # Install to .claude/integrations/ai/
    /// integration = {
    ///     source = "repo",
    ///     path = "integration.md",
    ///     version = "v2.0.0",
    ///     target = "integrations/ai"
    /// }
    /// ```
    #[serde(skip_serializing_if = "Option::is_none")]
    pub target: Option<String>,

    /// Custom filename for this dependency.
    ///
    /// Overrides the default filename (which is based on the dependency key).
    /// The filename should include the desired file extension. If not specified,
    /// the dependency will be installed using the key name with an automatically
    /// determined extension based on the resource type.
    ///
    /// # Examples
    ///
    /// ```toml
    /// [agents]
    /// # Install as "ai-assistant.md" instead of "my-ai.md"
    /// my-ai = {
    ///     source = "repo",
    ///     path = "agent.md",
    ///     version = "v1.0.0",
    ///     filename = "ai-assistant.md"
    /// }
    ///
    /// # Install with a different extension
    /// doc-agent = {
    ///     source = "repo",
    ///     path = "documentation.md",
    ///     version = "v2.0.0",
    ///     filename = "docs-helper.txt"
    /// }
    ///
    /// [scripts]
    /// # Rename a script during installation
    /// analyzer = {
    ///     source = "repo",
    ///     path = "scripts/data-analyzer-v3.py",
    ///     version = "v1.0.0",
    ///     filename = "analyze.py"
    /// }
    /// ```
    #[serde(skip_serializing_if = "Option::is_none")]
    pub filename: Option<String>,

    /// Transitive dependencies on other resources.
    ///
    /// This field is populated from metadata extracted from the resource file itself
    /// (YAML frontmatter in .md files or JSON fields in .json files).
    /// Maps resource type to list of dependency specifications.
    ///
    /// Example:
    /// ```toml
    /// # This would be extracted from the file's frontmatter/JSON, not specified in agpm.toml
    /// # { "agents": [{"path": "agents/helper.md", "version": "v1.0.0"}] }
    /// ```
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dependencies: Option<HashMap<String, Vec<DependencySpec>>>,

    /// Tool type (claude-code, opencode, agpm, or custom).
    ///
    /// Specifies which target AI coding assistant tool this resource is for. This determines
    /// where the resource is installed and how it's configured.
    ///
    /// When `None`, defaults are applied based on resource type:
    /// - Snippets default to "agpm" (shared infrastructure)
    /// - All other resources default to "claude-code"
    ///
    /// Omitted from TOML serialization when not specified.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool: Option<String>,

    /// Control directory structure preservation during installation.
    ///
    /// When `true`, only the filename is used for installation (e.g., `nested/dir/file.md` → `file.md`).
    /// When `false`, the full relative path is preserved (e.g., `nested/dir/file.md` → `nested/dir/file.md`).
    ///
    /// Default values by resource type (from tool configuration):
    /// - `agents`: `true` (flatten by default - no nested directories)
    /// - `commands`: `true` (flatten by default - no nested directories)
    /// - All others: `false` (preserve directory structure)
    ///
    /// # Examples
    ///
    /// ```toml
    /// [agents]
    /// # Default behavior (flatten=true) - installs as "helper.md"
    /// agent1 = { source = "repo", path = "agents/subdir/helper.md", version = "v1.0.0" }
    ///
    /// # Preserve structure - installs as "subdir/helper.md"
    /// agent2 = { source = "repo", path = "agents/subdir/helper.md", version = "v1.0.0", flatten = false }
    ///
    /// [snippets]
    /// # Default behavior (flatten=false) - installs as "utils/helper.md"
    /// snippet1 = { source = "repo", path = "snippets/utils/helper.md", version = "v1.0.0" }
    ///
    /// # Flatten - installs as "helper.md"
    /// snippet2 = { source = "repo", path = "snippets/utils/helper.md", version = "v1.0.0", flatten = true }
    /// ```
    #[serde(skip_serializing_if = "Option::is_none")]
    pub flatten: Option<bool>,

    /// Control whether the dependency should be installed to disk.
    ///
    /// When `false`, the dependency is resolved, fetched, and tracked in the lockfile,
    /// but the file is not written to the project directory. Instead, its content is
    /// made available in template context via `agpm.deps.<type>.<name>.content`.
    ///
    /// This is useful for snippet embedding use cases where you want to include
    /// content inline rather than as a separate file.
    ///
    /// Defaults to `true` (install the file).
    ///
    /// # Examples
    ///
    /// ```toml
    /// [snippets]
    /// # Embed content directly without creating a file
    /// best_practices = {
    ///     source = "repo",
    ///     path = "snippets/rust-best-practices.md",
    ///     version = "v1.0.0",
    ///     install = false
    /// }
    /// ```
    ///
    /// Then use in template:
    /// ```markdown
    /// {{ agpm.deps.snippets.best_practices.content }}
    /// ```
    #[serde(skip_serializing_if = "Option::is_none")]
    pub install: Option<bool>,

    /// Template variable overrides for this specific resource.
    ///
    /// Allows specializing generic resources for different use cases by overriding
    /// template variables. These variables are merged with (and take precedence over)
    /// the global `[project]` configuration when rendering this resource and resolving
    /// its transitive dependencies.
    ///
    /// This enables creating multiple variants of the same resource without duplication.
    /// For example, a single `backend-engineer.md` agent can be specialized for different
    /// languages by providing different `template_vars` for each variant.
    ///
    /// The structure matches the template namespace hierarchy (e.g., `{ "project": { "language": "golang" } }`).
    ///
    /// # Examples
    ///
    /// ```toml
    /// [agents]
    /// # Generic backend engineer agent specialized for different languages
    /// backend-engineer-golang = {
    ///     source = "community",
    ///     path = "agents/backend-engineer.md",
    ///     version = "v1.0.0",
    ///     filename = "backend-engineer-golang.md",
    ///     template_vars = { project = { language = "golang" } }
    /// }
    ///
    /// backend-engineer-python = {
    ///     source = "community",
    ///     path = "agents/backend-engineer.md",
    ///     version = "v1.0.0",
    ///     filename = "backend-engineer-python.md",
    ///     template_vars = { project = { language = "python", framework = "fastapi" } }
    /// }
    /// ```
    ///
    /// The agent at `agents/backend-engineer.md` can use templates like:
    /// ```markdown
    /// # Backend Engineer for {{ agpm.project.language }}
    ///
    /// ---
    /// dependencies:
    ///   snippets:
    ///     - path: ../best-practices/{{ agpm.project.language }}-best-practices.md
    /// ---
    /// ```
    ///
    /// Each variant will resolve its transitive dependencies using its specific `template_vars`,
    /// so the golang variant resolves `golang-best-practices.md` while python resolves
    /// `python-best-practices.md`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub template_vars: Option<serde_json::Value>,
}

impl ResourceDependency {
    /// Get the source repository name if this is a remote dependency.
    ///
    /// Returns the source name for remote dependencies (those that reference
    /// a Git repository), or `None` for local dependencies (those that reference
    /// local filesystem paths).
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
    ///
    /// // Local dependency - no source
    /// let local = ResourceDependency::Simple("../local/file.md".to_string());
    /// assert!(local.get_source().is_none());
    ///
    /// // Remote dependency - has source
    /// let remote = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("official".to_string()),
    ///     path: "agents/tool.md".to_string(),
    ///     version: Some("v1.0.0".to_string()),
    ///     branch: None,
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: Some("claude-code".to_string()),
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
    /// }));
    /// assert_eq!(remote.get_source(), Some("official"));
    /// assert_eq!(remote.get_source(), Some("official"));
    /// ```
    ///
    /// # Use Cases
    ///
    /// This method is commonly used to:
    /// - Determine if dependency resolution should use Git vs filesystem
    /// - Validate that referenced sources exist in the manifest
    /// - Filter dependencies by type (local vs remote)
    /// - Generate dependency graphs and reports
    #[must_use]
    pub fn get_source(&self) -> Option<&str> {
        match self {
            Self::Simple(_) => None,
            Self::Detailed(d) => d.source.as_deref(),
        }
    }

    /// Get the custom target directory for this dependency.
    ///
    /// Returns the custom target directory if specified, or `None` if the
    /// dependency should use the default installation location for its resource type.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
    ///
    /// // Dependency with custom target
    /// let custom = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("official".to_string()),
    ///     path: "agents/tool.md".to_string(),
    ///     version: Some("v1.0.0".to_string()),
    ///     target: Some("custom/tools".to_string()),
    ///     branch: None,
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: Some("claude-code".to_string()),
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
    /// }));
    /// assert_eq!(custom.get_target(), Some("custom/tools"));
    ///
    /// // Dependency without custom target
    /// let default = ResourceDependency::Simple("../local/file.md".to_string());
    /// assert!(default.get_target().is_none());
    /// ```
    #[must_use]
    pub fn get_target(&self) -> Option<&str> {
        match self {
            Self::Simple(_) => None,
            Self::Detailed(d) => d.target.as_deref(),
        }
    }

    /// Get the tool for this dependency.
    ///
    /// Returns the tool string if specified, or None if not specified.
    /// When None is returned, the caller should apply resource-type-specific defaults.
    ///
    /// # Returns
    ///
    /// - `Some(tool)` if tool is explicitly specified
    /// - `None` if no tool is configured (use resource-type default)
    #[must_use]
    pub fn get_tool(&self) -> Option<&str> {
        match self {
            Self::Detailed(d) => d.tool.as_deref(),
            Self::Simple(_) => None,
        }
    }

    /// Set the tool for this dependency.
    ///
    /// Only works for `Detailed` dependencies. Does nothing for `Simple` dependencies.
    pub fn set_tool(&mut self, tool: Option<String>) {
        if let Self::Detailed(d) = self {
            d.tool = tool;
        }
    }

    /// Get the custom filename for this dependency.
    ///
    /// Returns the custom filename if specified, or `None` if the
    /// dependency should use the default filename based on the dependency key.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
    ///
    /// // Dependency with custom filename
    /// let custom = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("official".to_string()),
    ///     path: "agents/tool.md".to_string(),
    ///     version: Some("v1.0.0".to_string()),
    ///     filename: Some("ai-assistant.md".to_string()),
    ///     branch: None,
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     dependencies: None,
    ///     tool: Some("claude-code".to_string()),
    ///     install: None,
    ///     flatten: None,
    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
    /// }));
    /// assert_eq!(custom.get_filename(), Some("ai-assistant.md"));
    ///
    /// // Dependency without custom filename
    /// let default = ResourceDependency::Simple("../local/file.md".to_string());
    /// assert!(default.get_filename().is_none());
    /// ```
    #[must_use]
    pub fn get_filename(&self) -> Option<&str> {
        match self {
            Self::Simple(_) => None,
            Self::Detailed(d) => d.filename.as_deref(),
        }
    }

    /// Get the flatten flag for this dependency.
    ///
    /// Returns the flatten setting if explicitly specified, or `None` if the
    /// dependency should use the default flatten behavior based on tool configuration.
    ///
    /// When `flatten = true`: Only the filename is used (e.g., `nested/dir/file.md` → `file.md`)
    /// When `flatten = false`: Full path is preserved (e.g., `nested/dir/file.md` → `nested/dir/file.md`)
    ///
    /// # Default Behavior (from tool configuration)
    ///
    /// - **Agents**: Default to `true` (flatten)
    /// - **Commands**: Default to `true` (flatten)
    /// - **All others**: Default to `false` (preserve structure)
    #[must_use]
    pub fn get_flatten(&self) -> Option<bool> {
        match self {
            Self::Simple(_) => None,
            Self::Detailed(d) => d.flatten,
        }
    }

    /// Get the install flag for this dependency.
    ///
    /// Returns the install setting if explicitly specified, or `None` to use the
    /// default behavior (install = true).
    ///
    /// When `install = false`: Dependency is resolved and content made available in
    /// template context, but file is not written to disk.
    ///
    /// When `install = true` (or `None`): Dependency is installed as a file.
    ///
    /// # Returns
    ///
    /// - `Some(false)` - Do not install the file, only make content available
    /// - `Some(true)` - Install the file normally
    /// - `None` - Use default behavior (install = true)
    #[must_use]
    pub fn get_install(&self) -> Option<bool> {
        match self {
            Self::Simple(_) => None,
            Self::Detailed(d) => d.install,
        }
    }

    /// Get the template variable overrides for this resource.
    ///
    /// Returns the resource-specific template variables that override the global
    /// `[project]` configuration. These variables are used when:
    /// - Rendering the resource file itself
    /// - Resolving the resource's transitive dependencies
    ///
    /// This allows creating specialized variants of generic resources without duplication.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
    /// use serde_json::json;
    ///
    /// // Resource with template variable overrides
    /// let resource = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("community".to_string()),
    ///     path: "agents/backend-engineer.md".to_string(),
    ///     version: Some("v1.0.0".to_string()),
    ///     branch: None,
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: Some("backend-engineer-golang.md".to_string()),
    ///     dependencies: None,
    ///     tool: Some("claude-code".to_string()),
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: Some(json!({ "project": { "language": "golang" } })),
    /// }));
    ///
    /// assert!(resource.get_template_vars().is_some());
    /// ```
    pub fn get_template_vars(&self) -> Option<&serde_json::Value> {
        match self {
            Self::Simple(_) => None,
            Self::Detailed(d) => d.template_vars.as_ref(),
        }
    }

    /// Get the path to the resource file.
    ///
    /// Returns the path component of the dependency, which is interpreted
    /// differently based on whether this is a local or remote dependency:
    ///
    /// - **Local dependencies**: Filesystem path relative to the manifest directory
    /// - **Remote dependencies**: Path within the Git repository
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
    ///
    /// // Local dependency - filesystem path
    /// let local = ResourceDependency::Simple("../shared/helper.md".to_string());
    /// assert_eq!(local.get_path(), "../shared/helper.md");
    ///
    /// // Remote dependency - repository path
    /// let remote = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("official".to_string()),
    ///     path: "agents/code-reviewer.md".to_string(),
    ///     version: Some("v1.0.0".to_string()),
    ///     branch: None,
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: Some("claude-code".to_string()),
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
    /// }));
    /// assert_eq!(remote.get_path(), "agents/code-reviewer.md");
    /// ```
    ///
    /// # Path Resolution
    ///
    /// The returned path should be processed appropriately based on the dependency type:
    /// - Local paths may need resolution against the manifest directory
    /// - Remote paths are used directly within the cloned repository
    /// - All paths should use forward slashes (/) for cross-platform compatibility
    #[must_use]
    pub fn get_path(&self) -> &str {
        match self {
            Self::Simple(path) => path,
            Self::Detailed(d) => &d.path,
        }
    }

    /// Check if this is a pattern-based dependency.
    ///
    /// Returns `true` if this dependency uses a glob pattern to match
    /// multiple resources, `false` if it specifies a single resource path.
    ///
    /// Patterns are detected by the presence of glob characters (`*`, `?`, `[`)
    /// in the path field.
    #[must_use]
    pub fn is_pattern(&self) -> bool {
        let path = self.get_path();
        path.contains('*') || path.contains('?') || path.contains('[')
    }

    /// Get the version constraint for dependency resolution.
    ///
    /// Returns the version constraint that should be used when resolving this
    /// dependency from a Git repository. For local dependencies, always returns `None`.
    ///
    /// # Priority Rules
    ///
    /// If both `version` and `git` fields are present in a detailed dependency,
    /// the `git` field takes precedence:
    ///
    /// ```rust,no_run
    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
    ///
    /// let dep = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("repo".to_string()),
    ///     path: "file.md".to_string(),
    ///     version: Some("v1.0.0".to_string()),  // This is ignored
    ///     branch: Some("develop".to_string()),   // This takes precedence over version
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: Some("claude-code".to_string()),
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
    /// }));
    ///
    /// assert_eq!(dep.get_version(), Some("develop"));
    /// ```
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
    ///
    /// // Local dependency - no version
    /// let local = ResourceDependency::Simple("../local/file.md".to_string());
    /// assert!(local.get_version().is_none());
    ///
    /// // Remote dependency with version
    /// let versioned = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("repo".to_string()),
    ///     path: "file.md".to_string(),
    ///     version: Some("v1.0.0".to_string()),
    ///     branch: None,
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: Some("claude-code".to_string()),
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
    /// }));
    /// assert_eq!(versioned.get_version(), Some("v1.0.0"));
    ///
    /// // Remote dependency with branch reference
    /// let branch_ref = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("repo".to_string()),
    ///     path: "file.md".to_string(),
    ///     version: None,
    ///     branch: Some("main".to_string()),
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: Some("claude-code".to_string()),
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
    /// }));
    /// assert_eq!(branch_ref.get_version(), Some("main"));
    /// ```
    ///
    /// # Version Formats
    ///
    /// Supported version constraint formats include:
    /// - Semantic versions: `"v1.0.0"`, `"1.2.3"`
    /// - Semantic version ranges: `"^1.0.0"`, `"~2.1.0"`
    /// - Branch names: `"main"`, `"develop"`, `"latest"`, `"feature/new"`
    /// - Git tags: `"release-2023"`, `"stable"`
    /// - Commit SHAs: `"a1b2c3d4e5f6..."`
    #[must_use]
    pub fn get_version(&self) -> Option<&str> {
        match self.resolution_mode() {
            crate::resolver::types::ResolutionMode::Version => {
                // Version path: return version constraint
                self.get_version_constraint()
            }
            crate::resolver::types::ResolutionMode::GitRef => {
                // Git path: return git reference (rev takes precedence)
                self.get_git_ref()
            }
        }
    }

    /// Check if this is a local filesystem dependency.
    ///
    /// Returns `true` if this dependency refers to a local file (no Git source),
    /// or `false` if it's a remote dependency that will be resolved from a
    /// Git repository.
    ///
    /// This is a convenience method equivalent to `self.get_source().is_none()`.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
    ///
    /// // Local dependency
    /// let local = ResourceDependency::Simple("../local/file.md".to_string());
    /// assert!(local.is_local());
    ///
    /// // Remote dependency
    /// let remote = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("official".to_string()),
    ///     path: "agents/tool.md".to_string(),
    ///     version: Some("v1.0.0".to_string()),
    ///     branch: None,
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: Some("claude-code".to_string()),
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
    /// }));
    /// assert!(!remote.is_local());
    ///
    /// // Local detailed dependency (no source specified)
    /// let local_detailed = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: None,
    ///     path: "../shared/tool.md".to_string(),
    ///     version: None,
    ///     branch: None,
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: Some("claude-code".to_string()),
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
    /// }));
    /// assert!(local_detailed.is_local());
    /// ```
    ///
    /// # Use Cases
    ///
    /// This method is useful for:
    /// - Choosing between filesystem and Git resolution strategies
    /// - Validation logic (local deps can't have versions)
    /// - Installation planning (local deps don't need caching)
    /// - Progress reporting (different steps for local vs remote)
    #[must_use]
    pub fn is_local(&self) -> bool {
        self.get_source().is_none()
    }

    /// Get the resolution mode for this dependency.
    ///
    /// Returns whether this dependency should be resolved using version constraints
    /// (semantic versioning with tags) or direct git references (branch/rev).
    ///
    /// # Returns
    ///
    /// - `ResolutionMode::Version` if this dependency uses `version` field or has no git reference
    /// - `ResolutionMode::GitRef` if this dependency uses `branch` or `rev` fields
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
    /// use agpm_cli::resolver::types::ResolutionMode;
    ///
    /// // Version path dependency
    /// let versioned = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("official".to_string()),
    ///     path: "agents/tool.md".to_string(),
    ///     version: Some("^1.0.0".to_string()),
    ///     branch: None,
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: None,
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: None,
    /// }));
    /// assert_eq!(versioned.resolution_mode(), ResolutionMode::Version);
    ///
    /// // Git path dependency
    /// let git_ref = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("official".to_string()),
    ///     path: "agents/tool.md".to_string(),
    ///     version: None,
    ///     branch: Some("main".to_string()),
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: None,
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: None,
    /// }));
    /// assert_eq!(git_ref.resolution_mode(), ResolutionMode::GitRef);
    /// ```
    #[must_use]
    pub fn resolution_mode(&self) -> crate::resolver::types::ResolutionMode {
        crate::resolver::types::ResolutionMode::from_dependency(self)
    }

    /// Get version constraint (Version path only).
    ///
    /// Returns the version constraint only for Version path dependencies.
    /// For Git path dependencies, returns None.
    ///
    /// # Returns
    ///
    /// - `Some(version)` if this is a Version path dependency with a version constraint
    /// - `None` for Git path dependencies or dependencies without version
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
    ///
    /// // Version constraint
    /// let versioned = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("official".to_string()),
    ///     path: "agents/tool.md".to_string(),
    ///     version: Some("^1.0.0".to_string()),
    ///     branch: None,
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: None,
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: None,
    /// }));
    /// assert_eq!(versioned.get_version_constraint(), Some("^1.0.0"));
    ///
    /// // Git reference - no version constraint
    /// let git_ref = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("official".to_string()),
    ///     path: "agents/tool.md".to_string(),
    ///     version: None,
    ///     branch: Some("main".to_string()),
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: None,
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: None,
    /// }));
    /// assert_eq!(git_ref.get_version_constraint(), None);
    /// ```
    #[must_use]
    pub fn get_version_constraint(&self) -> Option<&str> {
        match (self, self.resolution_mode()) {
            (Self::Detailed(d), crate::resolver::types::ResolutionMode::Version) => {
                d.version.as_deref()
            }
            _ => None,
        }
    }

    /// Get git reference (Git path only).
    ///
    /// Returns the git reference (branch or rev) only for Git path dependencies.
    /// For Version path dependencies, returns None.
    ///
    /// Rev takes precedence over branch if both are specified.
    ///
    /// # Returns
    ///
    /// - `Some(git_ref)` if this is a Git path dependency with branch or rev
    /// - `None` for Version path dependencies
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
    ///
    /// // Branch reference
    /// let branch_ref = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("official".to_string()),
    ///     path: "agents/tool.md".to_string(),
    ///     version: None,
    ///     branch: Some("main".to_string()),
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: None,
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: None,
    /// }));
    /// assert_eq!(branch_ref.get_git_ref(), Some("main"));
    ///
    /// // Version constraint - no git reference
    /// let versioned = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("official".to_string()),
    ///     path: "agents/tool.md".to_string(),
    ///     version: Some("^1.0.0".to_string()),
    ///     branch: None,
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: None,
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: None,
    /// }));
    /// assert_eq!(versioned.get_git_ref(), None);
    /// ```
    #[must_use]
    pub fn get_git_ref(&self) -> Option<&str> {
        match self {
            Self::Detailed(d) => {
                // Precedence: rev > branch
                d.rev.as_deref().or(d.branch.as_deref())
            }
            _ => None,
        }
    }

    /// Check if this dependency is mutable (can change without manifest changes).
    ///
    /// A dependency is considered mutable if:
    /// - It's a local dependency (no source, just a filesystem path)
    /// - It uses a branch reference (branches can be updated)
    /// - It uses a version that looks like a branch name (not semver)
    ///
    /// A dependency is considered immutable if:
    /// - It uses a `rev` field (explicitly pinned to a SHA)
    /// - It uses a semver version string (resolved to a specific tag)
    ///
    /// Immutable dependencies are safe for fast-path optimization because their
    /// content is locked by SHA commit hash after initial resolution.
    ///
    /// # Returns
    ///
    /// - `true` if the dependency can change without manifest changes
    /// - `false` if the dependency is locked to a specific commit
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use agpm_cli::manifest::{ResourceDependency, DetailedDependency};
    ///
    /// // Local dependency - always mutable
    /// let local = ResourceDependency::Simple("../local/file.md".to_string());
    /// assert!(local.is_mutable());
    ///
    /// // Branch reference - mutable
    /// let branch = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("repo".to_string()),
    ///     path: "file.md".to_string(),
    ///     version: None,
    ///     branch: Some("main".to_string()),
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: None,
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: None,
    /// }));
    /// assert!(branch.is_mutable());
    ///
    /// // Semver version - immutable (tags are stable)
    /// let versioned = ResourceDependency::Detailed(Box::new(DetailedDependency {
    ///     source: Some("repo".to_string()),
    ///     path: "file.md".to_string(),
    ///     version: Some("^1.0.0".to_string()),
    ///     branch: None,
    ///     rev: None,
    ///     command: None,
    ///     args: None,
    ///     target: None,
    ///     filename: None,
    ///     dependencies: None,
    ///     tool: None,
    ///     flatten: None,
    ///     install: None,
    ///     template_vars: None,
    /// }));
    /// assert!(!versioned.is_mutable());
    /// ```
    #[must_use]
    pub fn is_mutable(&self) -> bool {
        // Local dependencies are always mutable (filesystem can change)
        if self.is_local() {
            return true;
        }

        // Branch references are mutable (branches can be updated)
        match self {
            Self::Detailed(d) => {
                // If branch is explicitly set, it's mutable
                if d.branch.is_some() {
                    return true;
                }

                // If rev (SHA) is explicitly set, it's immutable
                if d.rev.is_some() {
                    return false;
                }

                // Check the version field for branch-like patterns
                if let Some(version) = &d.version {
                    return Self::is_branch_like_version(version);
                }

                // No version, branch, or rev means it's undefined (treat as mutable to be safe)
                true
            }
            // Simple string is always local, already handled above
            Self::Simple(_) => true,
        }
    }

    /// Check if a version string looks like a branch name rather than semver.
    ///
    /// Semver-like versions (immutable):
    /// - `v1.0.0`, `1.0.0`, `^1.0.0`, `~1.0.0`, `>=1.0.0`
    /// - Prefixed versions like `prefix-v1.0.0`, `prefix-^v1.0.0`
    /// - Full 40-character SHA hashes
    ///
    /// Branch-like versions (mutable):
    /// - `main`, `master`, `develop`
    /// - Any string without digits or semver operators
    ///
    /// Note: This is `pub(crate)` rather than private to enable direct testing
    /// in `resource_dependency_tests.rs`. It's only used internally by `is_mutable()`.
    #[cfg_attr(test, allow(dead_code))]
    pub(crate) fn is_branch_like_version(version: &str) -> bool {
        let version = version.trim();

        // Empty is undefined, treat as mutable
        if version.is_empty() {
            return true;
        }

        // Full SHA (40 hex chars) is immutable - it points to a specific commit
        if version.len() == 40 && version.chars().all(|c| c.is_ascii_hexdigit()) {
            return false;
        }

        // Check for semver operators at start or after a prefix
        // Patterns: ^, ~, >=, <=, >, <, = or version starting with digit/v
        let semver_start = |s: &str| {
            s.starts_with('^')
                || s.starts_with('~')
                || s.starts_with('>')
                || s.starts_with('<')
                || s.starts_with('=')
                || s.starts_with('v')
                || s.starts_with('V')
                || s.chars().next().is_some_and(|c| c.is_ascii_digit())
        };

        // Handle prefixed versions like "claude-code-agent-v1.0.0" or "prefix-^v1.0.0"
        // Find the last hyphen and check if what follows looks like semver
        if let Some(last_hyphen_pos) = version.rfind('-') {
            let after_hyphen = &version[last_hyphen_pos + 1..];
            if semver_start(after_hyphen) {
                return false; // It's a prefixed semver, not mutable
            }
        }

        // If the whole version looks like semver, it's not mutable
        if semver_start(version) {
            return false;
        }

        // Everything else (main, develop, feature/xyz) is branch-like and mutable
        true
    }
}