@@ -20,6 +20,65 @@ pub struct MountAddArgs {
2020 pub force : bool ,
2121}
2222
23+ #[ derive( Debug , Clone , PartialEq ) ]
24+ enum MountUpsertOutcome {
25+ Added ,
26+ AlreadyConfigured ,
27+ Replaced { replaced_mounts : Vec < ParsedMount > } ,
28+ }
29+
30+ fn upsert_mount_by_target (
31+ existing_mounts : & [ String ] ,
32+ mount_spec : & str ,
33+ parsed : & ParsedMount ,
34+ ) -> ( Vec < String > , MountUpsertOutcome ) {
35+ let mut retained = Vec :: with_capacity ( existing_mounts. len ( ) + 1 ) ;
36+ let mut same_target = Vec :: new ( ) ;
37+
38+ for existing in existing_mounts {
39+ match ParsedMount :: parse ( existing) {
40+ Ok ( existing_parsed) if existing_parsed. container_path == parsed. container_path => {
41+ same_target. push ( existing_parsed) ;
42+ }
43+ _ => retained. push ( existing. clone ( ) ) ,
44+ }
45+ }
46+
47+ let exact_match_count = same_target
48+ . iter ( )
49+ . filter ( |mount| mount. host_path == parsed. host_path && mount. read_only == parsed. read_only )
50+ . count ( ) ;
51+
52+ if exact_match_count == 1 && same_target. len ( ) == 1 {
53+ return (
54+ existing_mounts. to_vec ( ) ,
55+ MountUpsertOutcome :: AlreadyConfigured ,
56+ ) ;
57+ }
58+
59+ retained. push ( mount_spec. to_string ( ) ) ;
60+ if same_target. is_empty ( ) {
61+ ( retained, MountUpsertOutcome :: Added )
62+ } else {
63+ (
64+ retained,
65+ MountUpsertOutcome :: Replaced {
66+ replaced_mounts : same_target,
67+ } ,
68+ )
69+ }
70+ }
71+
72+ fn mount_to_spec ( mount : & ParsedMount ) -> String {
73+ let mode = if mount. read_only { ":ro" } else { "" } ;
74+ format ! (
75+ "{}:{}{}" ,
76+ mount. host_path. display( ) ,
77+ mount. container_path,
78+ mode
79+ )
80+ }
81+
2382pub async fn cmd_mount_add ( args : & MountAddArgs , quiet : bool , _verbose : u8 ) -> Result < ( ) > {
2483 // Parse the mount spec
2584 let parsed = ParsedMount :: parse ( & args. mount_spec ) ?;
@@ -44,43 +103,154 @@ pub async fn cmd_mount_add(args: &MountAddArgs, quiet: bool, _verbose: u8) -> Re
44103
45104 // Load config and add mount
46105 let mut config = load_config_or_default ( ) ?;
106+ let host_str = parsed. host_path . to_string_lossy ( ) . to_string ( ) ;
107+ let ( updated_mounts, outcome) =
108+ upsert_mount_by_target ( & config. mounts , & args. mount_spec , & parsed) ;
109+ config. mounts = updated_mounts;
110+ save_config ( & config) ?;
47111
48- // Check for duplicate (by host path)
49- let host_str = parsed. host_path . to_string_lossy ( ) ;
50- let already_exists = config. mounts . iter ( ) . any ( |m| {
51- ParsedMount :: parse ( m)
52- . map ( |p| p. host_path . to_string_lossy ( ) == host_str)
53- . unwrap_or ( false )
54- } ) ;
112+ if quiet {
113+ return Ok ( ( ) ) ;
114+ }
55115
56- if already_exists {
57- if !quiet {
116+ match outcome {
117+ MountUpsertOutcome :: AlreadyConfigured => {
58118 println ! (
59- "Mount for {} already configured. Remove first with: occ mount remove {}" ,
119+ "Mount already configured: {} -> {}" ,
60120 style( & host_str) . cyan( ) ,
61- host_str
121+ style ( & parsed . container_path ) . cyan ( )
62122 ) ;
123+ return Ok ( ( ) ) ;
124+ }
125+ MountUpsertOutcome :: Added => {
126+ let mode = if parsed. read_only { "ro" } else { "rw" } ;
127+ println ! (
128+ "Added mount: {} -> {} ({mode})" ,
129+ style( & host_str) . cyan( ) ,
130+ style( & parsed. container_path) . cyan( ) ,
131+ ) ;
132+ }
133+ MountUpsertOutcome :: Replaced { replaced_mounts } => {
134+ let mode = if parsed. read_only { "ro" } else { "rw" } ;
135+ println ! (
136+ "Replaced mount target {} with {} -> {} ({mode})" ,
137+ style( & parsed. container_path) . cyan( ) ,
138+ style( & host_str) . cyan( ) ,
139+ style( & parsed. container_path) . cyan( ) ,
140+ ) ;
141+ println ! ( ) ;
142+ println ! ( "Previous mount(s) for this target:" ) ;
143+ for previous in & replaced_mounts {
144+ let previous_mode = if previous. read_only { "ro" } else { "rw" } ;
145+ println ! (
146+ " - {} -> {} ({previous_mode})" ,
147+ previous. host_path. display( ) ,
148+ previous. container_path
149+ ) ;
150+ }
151+ println ! ( ) ;
152+ println ! ( "If this replacement was not intended:" ) ;
153+ println ! (
154+ " 1) Remove the new mount: {}" ,
155+ style( format!( "occ mount remove {host_str}" ) ) . cyan( )
156+ ) ;
157+ if let Some ( previous) = replaced_mounts. last ( ) {
158+ println ! (
159+ " 2) Re-add a previous mount: {}" ,
160+ style( format!( "occ mount add {}" , mount_to_spec( previous) ) ) . cyan( )
161+ ) ;
162+ } else {
163+ println ! (
164+ " 2) Re-add your previous mount with: {}" ,
165+ style( "occ mount add /host/path:/container/path[:ro]" ) . cyan( )
166+ ) ;
167+ }
63168 }
64- return Ok ( ( ) ) ;
65169 }
66170
67- config. mounts . push ( args. mount_spec . clone ( ) ) ;
68- save_config ( & config) ?;
171+ println ! ( ) ;
172+ println ! (
173+ "{}" ,
174+ style( "Note: Run `occ restart` to apply mount changes. If mounts changed, you will be prompted to recreate the container." ) . dim( )
175+ ) ;
176+
177+ Ok ( ( ) )
178+ }
179+
180+ #[ cfg( test) ]
181+ mod tests {
182+ use super :: * ;
183+
184+ #[ test]
185+ fn upsert_mount_by_target_exact_same_is_noop ( ) {
186+ let existing = vec ! [
187+ "/host/a:/home/opencode/workspace" . to_string( ) ,
188+ "/host/b:/home/opencode/.cache/opencode" . to_string( ) ,
189+ ] ;
190+ let parsed = ParsedMount :: parse ( "/host/a:/home/opencode/workspace" ) . unwrap ( ) ;
69191
70- if !quiet {
71- let mode = if parsed. read_only { "ro" } else { "rw" } ;
72- println ! (
73- "Added mount: {} -> {} ({})" ,
74- style( & host_str) . cyan( ) ,
75- style( & parsed. container_path) . cyan( ) ,
76- mode
192+ let ( updated, outcome) =
193+ upsert_mount_by_target ( & existing, "/host/a:/home/opencode/workspace" , & parsed) ;
194+
195+ assert_eq ! ( updated, existing) ;
196+ assert_eq ! ( outcome, MountUpsertOutcome :: AlreadyConfigured ) ;
197+ }
198+
199+ #[ test]
200+ fn upsert_mount_by_target_replaces_same_target_with_new_host ( ) {
201+ let existing = vec ! [
202+ "/host/old:/home/opencode/workspace" . to_string( ) ,
203+ "/host/cache:/home/opencode/.cache/opencode" . to_string( ) ,
204+ ] ;
205+ let parsed = ParsedMount :: parse ( "/host/new:/home/opencode/workspace" ) . unwrap ( ) ;
206+
207+ let ( updated, outcome) =
208+ upsert_mount_by_target ( & existing, "/host/new:/home/opencode/workspace" , & parsed) ;
209+
210+ assert_eq ! (
211+ updated,
212+ vec![
213+ "/host/cache:/home/opencode/.cache/opencode" . to_string( ) ,
214+ "/host/new:/home/opencode/workspace" . to_string( ) ,
215+ ]
77216 ) ;
78- println ! ( ) ;
79- println ! (
80- "{}" ,
81- style( "Note: Restart the container for changes to take effect." ) . dim( )
217+ assert_eq ! (
218+ outcome,
219+ MountUpsertOutcome :: Replaced {
220+ replaced_mounts: vec![
221+ ParsedMount :: parse( "/host/old:/home/opencode/workspace" ) . unwrap( ) ,
222+ ] ,
223+ }
82224 ) ;
83225 }
84226
85- Ok ( ( ) )
227+ #[ test]
228+ fn upsert_mount_by_target_replaces_multiple_stale_targets ( ) {
229+ let existing = vec ! [
230+ "/host/old1:/home/opencode/workspace" . to_string( ) ,
231+ "/host/cache:/home/opencode/.cache/opencode" . to_string( ) ,
232+ "/host/old2:/home/opencode/workspace:ro" . to_string( ) ,
233+ ] ;
234+ let parsed = ParsedMount :: parse ( "/host/new:/home/opencode/workspace" ) . unwrap ( ) ;
235+
236+ let ( updated, outcome) =
237+ upsert_mount_by_target ( & existing, "/host/new:/home/opencode/workspace" , & parsed) ;
238+
239+ assert_eq ! (
240+ updated,
241+ vec![
242+ "/host/cache:/home/opencode/.cache/opencode" . to_string( ) ,
243+ "/host/new:/home/opencode/workspace" . to_string( ) ,
244+ ]
245+ ) ;
246+ assert_eq ! (
247+ outcome,
248+ MountUpsertOutcome :: Replaced {
249+ replaced_mounts: vec![
250+ ParsedMount :: parse( "/host/old1:/home/opencode/workspace" ) . unwrap( ) ,
251+ ParsedMount :: parse( "/host/old2:/home/opencode/workspace:ro" ) . unwrap( ) ,
252+ ] ,
253+ }
254+ ) ;
255+ }
86256}
0 commit comments