c5f3bde4 Andy McCall 1 month ago ↶
1
2
3
4
5
6
7
8
use super :: edit:: { after_web_commit, signer_for} ;
9
use super :: settings:: { csrf, require} ;
10
use super :: { lookup_role, Error, Page, RepoShell, Shell} ;
11
use crate :: auth:: { Access, Viewer} ;
12
use crate :: repos:: edits;
13
use crate :: repos:: view:: { self , FileBody} ;
14
use crate :: Shared;
15
use askama:: Template;
16
use axum:: extract:: { Form, Path, State} ;
17
use axum:: response:: { IntoResponse, Redirect, Response} ;
18
use oxy_core:: repo:: FileEdit;
19
use std:: collections:: HashMap;
20
21
22
# [ derive ( Clone, Debug, PartialEq, Eq ) ]
23
pub enum Piece {
24
25
Plain( String ) ,
26
Region( Region) ,
27
}
28
29
# [ derive ( Clone, Debug, PartialEq, Eq ) ]
30
pub struct Region {
31
32
pub n : usize ,
33
pub sides : Vec< Side> ,
34
}
35
36
# [ derive ( Clone, Debug, PartialEq, Eq ) ]
37
pub struct Side {
38
39
pub label : String,
40
41
pub text : String,
42
43
44
pub deletion : bool ,
45
}
46
47
impl Side {
48
49
pub fn kept ( & self ) -> & str {
50
if self .deletion {
51
" "
52
} else {
53
& self .text
54
}
55
}
56
}
57
58
impl Region {
59
60
pub fn all ( & self ) -> String {
61
self .sides.iter ( ) .map ( Side:: kept) .collect ( )
62
}
63
}
64
65
66
67
pub fn parse ( marked : & str ) -> Option< Vec< Piece> > {
68
let mut out = Vec :: new( ) ;
69
let mut plain = String :: new( ) ;
70
let mut region: Option< Region> = None ;
71
let mut lines = marked.split_inclusive ( ' \n ' ) .peekable ( ) ;
72
while let Some ( line) = lines.next ( ) {
73
let bare = line.trim_end_matches ( ' \n ' ) ;
74
if let Some ( rest) = bare.strip_prefix ( " <<<<<<< oxy conflict " ) {
75
if region.is_some ( ) {
76
return None ;
77
}
78
if ! plain.is_empty ( ) {
79
out.push ( Piece:: Plain( std:: mem:: take( & mut plain) ) ) ;
80
}
81
let ( n, label) = header ( rest) ? ;
82
let mut side = Side { label, text: String :: new( ) , deletion: false } ;
83
if lines.peek ( ) .map ( | l | l.trim_end_matches ( ' \n ' ) ) = = Some ( " -------" ) {
84
lines.next ( ) ;
85
side.deletion = true ;
86
}
87
region = Some ( Region { n, sides: vec! [ side] } ) ;
88
} else if let Some ( rest) = bare.strip_prefix ( " ======= oxy conflict " ) {
89
let r = region.as_mut ( ) ? ;
90
let ( n, label) = header ( rest) ? ;
91
if n ! = r.n {
92
return None ;
93
}
94
r.sides.push ( Side { label, text: String :: new( ) , deletion: false } ) ;
95
} else if bare.starts_with ( " >>>>>>> oxy conflict " ) {
96
out.push ( Piece:: Region( region.take ( ) ? ) ) ;
97
} else {
98
match region.as_mut ( ) {
99
Some ( r) => r.sides.last_mut ( ) ? .text.push_str ( line) ,
100
None => plain.push_str ( line) ,
101
}
102
}
103
}
104
if region.is_some ( ) {
105
return None ;
106
}
107
if ! plain.is_empty ( ) {
108
out.push ( Piece:: Plain( plain) ) ;
109
}
110
Some ( out)
111
}
112
113
114
fn header ( rest : & str ) -> Option< ( usize , String) > {
115
let ( n, tail) = rest.split_once ( " of " ) ? ;
116
let label = tail.split_once ( " : " ) .map ( | ( _, l ) | l.to_string ( ) ) .unwrap_or_default ( ) ;
117
Some ( ( n.trim ( ) .parse ( ) .ok ( ) ? , label) )
118
}
119
120
121
# [ derive ( Clone, Debug, PartialEq, Eq ) ]
122
pub enum Choice {
123
Side( usize ) ,
124
All,
125
None ,
126
Text( String ) ,
127
}
128
129
130
131
pub fn assemble ( pieces : & [Piece], choices : & HashMap< usize , Choice> ) -> Result< String, String> {
132
let mut out = String :: new( ) ;
133
for p in pieces {
134
match p {
135
Piece:: Plain( t) => out.push_str ( t) ,
136
Piece:: Region( r) => match choices.get ( & r.n) {
137
Some ( Choice:: Side( i) ) => {
138
out.push_str ( r.sides.get ( i.wrapping_sub ( 1 ) ) .ok_or_else ( | | format! ( " conflict {} has no side {i} " , r.n) ) ? .kept ( ) )
139
}
140
Some ( Choice:: All) => out.push_str ( & r.all ( ) ) ,
141
Some ( Choice:: None) => { }
142
Some ( Choice:: Text( t) ) => {
143
let t = t.replace ( " \r \n " , " \n " ) ;
144
out.push_str ( & t) ;
145
if ! t.is_empty ( ) & & ! t.ends_with ( ' \n ' ) {
146
out.push ( ' \n ' ) ;
147
}
148
}
149
None => return Err ( format! ( " choose how to resolve conflict {} " , r.n) ) ,
150
} ,
151
}
152
}
153
Ok ( out)
154
}
155
fd958fbc Andy McCall 1 month ago ↶
156
157
# [ derive ( Clone, Debug ) ]
158
pub struct Candidate {
159
pub path : String,
160
pub content : Vec< u8 > ,
161
162
pub preview : Option< String> ,
163
}
164
165
166
167
168
fn name_candidates ( r : & oxy_core:: repo:: Repo, snapshot : & oxy_core:: state:: Snapshot, path : & str ) -> Result< Option< Vec< Candidate> > , String> {
169
use oxy_core:: objects:: NodeKind;
170
use oxy_core:: state:: FileContent;
171
use oxy_core:: tree:: TreeConflict;
172
let by_node = snapshot.tree.by_node ( ) ;
173
for c in & snapshot.tree.conflicts {
174
let TreeConflict:: Name { parent, name, nodes } = c else { continue } ;
175
let full = if parent.is_root ( ) {
176
name.clone ( )
177
} else {
178
match by_node.get ( parent) {
179
Some ( e) => format! ( " {} /{name} " , e.path) ,
180
None => continue ,
181
}
182
} ;
183
if full ! = path {
184
continue ;
185
}
186
let mut out = Vec :: new( ) ;
187
for n in nodes {
188
let Some ( e) = by_node.get ( n) else { continue } ;
189
let f = snapshot.files.get ( & e.path) .ok_or ( " a candidate is missing" ) ? ;
190
if f.kind ! = NodeKind:: File {
191
return Err ( " This conflict is between directories or links; resolve it from a client." .into ( ) ) ;
192
}
193
let content = match & f.content {
194
FileContent:: Text { bytes, .. } => bytes.clone ( ) ,
195
FileContent:: Opaque { blobs } => match blobs.first ( ) {
196
Some ( b) => r.read_blob ( b) .map_err ( | e | e.to_string ( ) ) ? ,
197
None => vec! [ ] ,
198
} ,
199
FileContent:: Dir => continue ,
200
} ;
201
let preview = std:: str:: from_utf8( & content) .ok ( ) .map ( | t | t.lines ( ) .take ( 40 ) .collect:: < Vec< _ > > ( ) .join ( " \n " ) ) ;
202
out.push ( Candidate { path: e.path.clone ( ) , content, preview } ) ;
203
}
204
out.sort_by ( | a , b | a.path.cmp ( & b.path) ) ;
205
return Ok ( Some ( out) ) ;
206
}
207
Ok ( None )
208
}
209
210
211
enum Target {
212
213
Lines( String ) ,
214
215
Name( Vec< Candidate> ) ,
216
}
217
218
# [ derive ( Template ) ]
219
# [ template ( path = " resolve_name.html" ) ]
220
pub struct NamePage {
221
shell : Shell,
222
repo : RepoShell,
223
bookmark : String,
224
path : String,
225
state : String,
226
candidates : Vec< Candidate> ,
227
delegation : Option< String> ,
228
error : Option< String> ,
229
message : String,
230
}
231
c5f3bde4 Andy McCall 1 month ago ↶
232
# [ derive ( Template ) ]
233
# [ template ( path = " resolve.html" ) ]
234
pub struct ResolvePage {
235
shell : Shell,
236
repo : RepoShell,
237
bookmark : String,
238
path : String,
239
state : String,
240
pieces : Vec< Piece> ,
241
delegation : Option< String> ,
242
error : Option< String> ,
243
message : String,
244
}
245
fd958fbc Andy McCall 1 month ago ↶
246
247
async fn load ( app : & Shared, shell : & Shell, repo : & crate:: repos:: RepoName, bookmark : & str , path : & str ) -> Result< ( String, Target) , Error> {
c5f3bde4 Andy McCall 1 month ago ↶
248
let limit = app.config.limits.render_limit.0 ;
249
let ( b, p) = ( bookmark.to_string ( ) , path.to_string ( ) ) ;
fd958fbc Andy McCall 1 month ago ↶
250
let found = app
c5f3bde4 Andy McCall 1 month ago ↶
251
.repos
252
.read ( repo, move | r, cache| {
fd958fbc Andy McCall 1 month ago ↶
253
let Some ( st) = r.view ( ) ? .bookmarks.get ( & b) .copied ( ) else { return Ok ( None ) } ;
254
let heads = r.store ( ) .get_state ( & st) ? .heads;
255
let loaded = cache.load ( r, & heads) ? ;
256
let names = name_candidates ( r, & loaded.snapshot, & p) ;
257
let file = if names.as_ref ( ) .is_ok_and ( | n | n.is_some ( ) ) {
258
None
259
} else {
260
let target = view:: resolve( r, cache, & b) ? ;
261
Some ( view:: file( r, cache, target, & p, limit) )
262
} ;
263
Ok ( Some ( ( st.to_hex ( ) , names, file) ) )
c5f3bde4 Andy McCall 1 month ago ↶
264
} )
265
.await
266
.map_err ( | e | Error:: from_repo( shell.clone ( ) , e) ) ? ;
fd958fbc Andy McCall 1 month ago ↶
267
let Some ( ( state, names, file) ) = found else { return Err ( Error:: bad( shell.clone ( ) , " Conflicts are resolved on a bookmark." ) ) } ;
268
match names {
269
Ok ( Some ( c) ) => return Ok ( ( state, Target:: Name( c) ) ) ,
270
Err ( msg) => return Err ( Error:: bad( shell.clone ( ) , & msg) ) ,
271
Ok ( None ) => { }
272
}
273
let file = match file {
274
Some ( Ok ( f) ) => f,
275
Some ( Err ( e) ) => return Err ( Error:: from_repo( shell.clone ( ) , e) ) ,
276
None => return Err ( Error:: bad( shell.clone ( ) , " Nothing to resolve there." ) ) ,
277
} ;
c5f3bde4 Andy McCall 1 month ago ↶
278
match file.body {
fd958fbc Andy McCall 1 month ago ↶
279
FileBody:: Text( t) if file.conflicted => Ok ( ( state, Target:: Lines( t) ) ) ,
c5f3bde4 Andy McCall 1 month ago ↶
280
FileBody:: Text( _ ) => Err ( Error:: bad( shell.clone ( ) , " That file has no conflicts on this bookmark." ) ) ,
fd958fbc Andy McCall 1 month ago ↶
281
_ => Err ( Error:: bad( shell.clone ( ) , " Only text files within the render limit, and name conflicts, are resolved in the browser." ) ) ,
c5f3bde4 Andy McCall 1 month ago ↶
282
}
283
}
284
285
# [ allow ( clippy::too_many_arguments ) ]
286
async fn page (
287
app : & Shared,
288
viewer : & Viewer,
289
owner : & str ,
290
name : & str ,
291
bookmark : & str ,
292
path : & str ,
293
error : Option< String> ,
294
message : Option< String> ,
295
) -> Result< Page< ResolvePage> , Error> {
296
let shell = Shell:: new( app, & format! ( " /{owner} /{name} /resolve/{bookmark} /{path} " ) , viewer) ;
297
let me = require ( viewer, & shell) ? ;
298
let ( repo, row, role) = lookup_role ( app, & shell, viewer, owner, name, Access:: Write) .await? ;
fd958fbc Andy McCall 1 month ago ↶
299
let ( state, target) = load ( app, & shell, & repo, bookmark, path) .await? ;
300
let text = match target {
301
Target:: Lines( t) => t,
302
Target:: Name( _ ) => return Err ( Error:: bad( shell, " That is a name conflict." ) ) ,
303
} ;
c5f3bde4 Andy McCall 1 month ago ↶
304
let Some ( pieces) = parse ( & text) else {
305
return Err ( Error:: bad( shell, " The conflict markers could not be read; resolve the file in the plain editor." ) ) ;
306
} ;
307
let delegation = signer_for ( app, me.account.id) .await.err ( ) ;
308
Ok ( Page( ResolvePage {
309
shell,
310
repo: RepoShell:: new( app, & row, " conflicts" ) .with_role ( role) ,
311
bookmark: bookmark.to_string ( ) ,
312
path: path.to_string ( ) ,
313
state,
314
pieces,
315
delegation,
316
error,
317
message: message.unwrap_or_else ( | | format! ( " Resolve the conflicts in {path} " ) ) ,
318
} ) )
319
}
320
321
pub async fn form (
322
State( app ) : State< Shared> ,
323
viewer : Viewer,
324
Path( (owner , name , bookmark , path ) ) : Path< ( String, String, String, String) > ,
325
) -> Result< Response, Error> {
fd958fbc Andy McCall 1 month ago ↶
326
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /resolve/{bookmark} /{path} " ) , & viewer) ;
c5f3bde4 Andy McCall 1 month ago ↶
327
let ( repo, _ , _ ) = lookup_role ( & app, & shell, & viewer, & owner, & name, Access:: Write) .await? ;
fd958fbc Andy McCall 1 month ago ↶
328
match load ( & app, & shell, & repo, & bookmark, & path) .await? .1 {
329
330
Target:: Lines( text) if parse ( & text) .is_none ( ) => {
331
Ok ( Redirect:: to( & format! ( " /{owner} /{name} /edit/{bookmark} /{path} " ) ) .into_response ( ) )
332
}
333
Target:: Lines( _ ) => Ok ( page ( & app, & viewer, & owner, & name, & bookmark, & path, None , None ) .await? .into_response ( ) ) ,
334
Target:: Name( _ ) => Ok ( name_page ( & app, & viewer, & owner, & name, & bookmark, & path, None , None ) .await? .into_response ( ) ) ,
c5f3bde4 Andy McCall 1 month ago ↶
335
}
336
}
337
fd958fbc Andy McCall 1 month ago ↶
338
# [ allow ( clippy::too_many_arguments ) ]
339
async fn name_page (
340
app : & Shared,
341
viewer : & Viewer,
342
owner : & str ,
343
name : & str ,
344
bookmark : & str ,
345
path : & str ,
346
error : Option< String> ,
347
message : Option< String> ,
348
) -> Result< Page< NamePage> , Error> {
349
let shell = Shell:: new( app, & format! ( " /{owner} /{name} /resolve/{bookmark} /{path} " ) , viewer) ;
350
let me = require ( viewer, & shell) ? ;
351
let ( repo, row, role) = lookup_role ( app, & shell, viewer, owner, name, Access:: Write) .await? ;
352
let ( state, target) = load ( app, & shell, & repo, bookmark, path) .await? ;
353
let Target:: Name( candidates) = target else { return Err ( Error:: bad( shell, " That is not a name conflict." ) ) } ;
354
let delegation = signer_for ( app, me.account.id) .await.err ( ) ;
355
Ok ( Page( NamePage {
356
shell,
357
repo: RepoShell:: new( app, & row, " conflicts" ) .with_role ( role) ,
358
bookmark: bookmark.to_string ( ) ,
359
path: path.to_string ( ) ,
360
state,
361
candidates,
362
delegation,
363
error,
364
message: message.unwrap_or_else ( | | format! ( " Resolve the name conflict at {path} " ) ) ,
365
} ) )
366
}
367
c5f3bde4 Andy McCall 1 month ago ↶
368
pub async fn save (
369
State( app ) : State< Shared> ,
370
viewer : Viewer,
371
Path( (owner , name , bookmark , path ) ) : Path< ( String, String, String, String) > ,
372
Form( f) : Form< HashMap< String, String> > ,
373
) -> Result< Response, Error> {
374
let shell = Shell:: new( & app, & format! ( " /{owner} /{name} /resolve/{bookmark} /{path} " ) , & viewer) ;
375
let me = require ( & viewer, & shell) ? ;
376
csrf ( & viewer, f.get ( " _csrf" ) .map ( String :: as_str) .unwrap_or ( " " ) , & shell) ? ;
377
let ( repo, row, _ ) = lookup_role ( & app, & shell, & viewer, & owner, & name, Access:: Write) .await? ;
378
let message = f.get ( " message" ) .map ( | m | m.trim ( ) .to_string ( ) ) .unwrap_or_default ( ) ;
379
let again = | msg : String| {
380
let ( app, viewer, owner, name, bookmark, path, message) =
381
( app.clone ( ) , viewer.clone ( ) , owner.clone ( ) , name.clone ( ) , bookmark.clone ( ) , path.clone ( ) , message.clone ( ) ) ;
382
async move { page ( & app, & viewer, & owner, & name, & bookmark, & path, Some ( msg) , Some ( message) ) .await.map ( IntoResponse:: into_response) }
383
} ;
fd958fbc Andy McCall 1 month ago ↶
384
let ( state, target) = load ( & app, & shell, & repo, & bookmark, & path) .await? ;
385
let stale = f.get ( " state" ) ! = Some ( & state) ;
386
let text = match target {
387
Target:: Lines( t) => t,
388
Target:: Name( candidates) => {
389
let again = | msg : String| {
390
let ( app, viewer, owner, name, bookmark, path, message) =
391
( app.clone ( ) , viewer.clone ( ) , owner.clone ( ) , name.clone ( ) , bookmark.clone ( ) , path.clone ( ) , message.clone ( ) ) ;
392
async move {
393
name_page ( & app, & viewer, & owner, & name, & bookmark, & path, Some ( msg) , Some ( message) )
394
.await
395
.map ( IntoResponse:: into_response)
396
}
397
} ;
398
if stale {
399
return again ( format! ( " {bookmark} has moved since this page was loaded; the candidates below are as they are now." ) ) .await;
400
}
401
let Some ( keep) = f.get ( " keep" ) .and_then ( | k | k.parse:: < usize > ( ) .ok ( ) ) .and_then ( | k | candidates.get ( k) ) else {
402
return again ( " Choose the candidate to keep." .into ( ) ) .await;
403
} ;
404
if message.is_empty ( ) {
405
return again ( " Describe the resolution." .into ( ) ) .await;
406
}
407
let signer = match signer_for ( & app, me.account.id) .await {
408
Ok ( s) => s,
409
Err ( msg) => return again ( msg) .await,
410
} ;
411
412
413
414
let mut edit: Vec< FileEdit> = candidates.iter ( ) .map ( | c | FileEdit { path: c.path.clone ( ) , content: None } ) .collect ( ) ;
415
edit.push ( FileEdit { path: path.clone ( ) , content: Some ( keep.content.clone ( ) ) } ) ;
416
let done = match edits:: commit( & app.repos, & repo, bookmark.clone ( ) , bookmark.clone ( ) , edit, message.clone ( ) , signer) .await {
417
Ok ( d) => d,
418
Err ( e) => return again ( e.to_string ( ) ) .await,
419
} ;
420
after_web_commit ( & app, & me.account, & row, & owner, & name, & done, & message) .await;
421
return Ok ( Redirect:: to( & format! ( " /{owner} /{name} /change/{} " , done.change.to_hex ( ) ) ) .into_response ( ) ) ;
422
}
423
} ;
424
if stale {
c5f3bde4 Andy McCall 1 month ago ↶
425
return again ( format! ( " {bookmark} has moved since this page was loaded; the conflicts below are as they are now." ) ) .await;
426
}
427
let Some ( pieces) = parse ( & text) else { return again ( " The conflict markers could not be read." .into ( ) ) .await } ;
428
let mut choices = HashMap:: new( ) ;
429
for p in & pieces {
430
if let Piece:: Region( r) = p {
431
let choice = match f.get ( & format! ( " r{} " , r.n) ) .map ( String :: as_str) {
432
Some ( " all" ) => Choice:: All,
433
Some ( " none" ) => Choice:: None,
434
Some ( " text" ) => Choice:: Text( f.get ( & format! ( " t{} " , r.n) ) .cloned ( ) .unwrap_or_default ( ) ) ,
435
Some ( s) => match s.strip_prefix ( " side" ) .and_then ( | i | i.parse ( ) .ok ( ) ) {
436
Some ( i) => Choice:: Side( i) ,
437
None => continue ,
438
} ,
439
None => continue ,
440
} ;
441
choices.insert ( r.n, choice) ;
442
}
443
}
444
let content = match assemble ( & pieces, & choices) {
445
Ok ( c) => c,
446
Err ( msg) => return again ( msg) .await,
447
} ;
448
if message.is_empty ( ) {
449
return again ( " Describe the resolution." .into ( ) ) .await;
450
}
451
let signer = match signer_for ( & app, me.account.id) .await {
452
Ok ( s) => s,
453
Err ( msg) => return again ( msg) .await,
454
} ;
455
let edit = vec! [ FileEdit { path: path.clone ( ) , content: Some ( content.into_bytes ( ) ) } ] ;
456
let done = match edits:: commit( & app.repos, & repo, bookmark.clone ( ) , bookmark.clone ( ) , edit, message.clone ( ) , signer) .await {
457
Ok ( d) => d,
458
Err ( e) => return again ( e.to_string ( ) ) .await,
459
} ;
460
after_web_commit ( & app, & me.account, & row, & owner, & name, & done, & message) .await;
461
Ok ( Redirect:: to( & format! ( " /{owner} /{name} /change/{} " , done.change.to_hex ( ) ) ) .into_response ( ) )
462
}
463
464
# [ cfg ( test ) ]
465
mod tests {
466
use super :: * ;
467
468
const MARKED : & str = " a\n <<<<<<< oxy conflict 1 of 2, side 1 of 2: change 0202\n from-2\n ======= oxy conflict 1 of 2, side 2 of 2: change 0303\n from-3\n more-3\n >>>>>>> oxy conflict 1 of 2 end\n b\n <<<<<<< oxy conflict 2 of 2, side 1 of 2: deleted by change 0404\n -------\n gone\n ======= oxy conflict 2 of 2, side 2 of 2: change 0505\n added\n >>>>>>> oxy conflict 2 of 2 end\n c\n " ;
469
470
# [ test ]
471
fn markers_parse_into_regions_and_sides ( ) {
472
let p = parse ( MARKED ) .unwrap ( ) ;
473
assert_eq! ( p.len ( ) , 5 ) ;
474
assert_eq! ( p[ 0 ] , Piece:: Plain( " a\n " .into ( ) ) ) ;
475
let Piece:: Region( r1) = & p[ 1 ] else { panic! ( ) } ;
476
assert_eq! ( ( r1.n, r1.sides.len ( ) ) , ( 1 , 2 ) ) ;
477
assert_eq! ( ( r1.sides[ 0 ] .label.as_str ( ) , r1.sides[ 0 ] .text.as_str ( ) ) , ( " change 0202" , " from-2\n " ) ) ;
478
assert_eq! ( r1.sides[ 1 ] .text, " from-3\n more-3\n " ) ;
479
let Piece:: Region( r2) = & p[ 3 ] else { panic! ( ) } ;
480
assert! ( r2.sides[ 0 ] .deletion & & r2.sides[ 0 ] .text = = " gone\n " & & r2.sides[ 0 ] .kept ( ) .is_empty ( ) ) ;
481
assert_eq! ( r2.sides[ 0 ] .label, " deleted by change 0404" ) ;
482
assert_eq! ( p[ 4 ] , Piece:: Plain( " c\n " .into ( ) ) ) ;
483
assert! ( parse ( " <<<<<<< oxy conflict 1 of 1, side 1 of 2: change x\n no end\n " ) .is_none ( ) ) ;
484
assert_eq! ( parse ( " plain\n " ) .unwrap ( ) , vec! [ Piece:: Plain( " plain\n " .into ( ) ) ] ) ;
485
}
486
487
# [ test ]
488
fn choices_assemble_the_file ( ) {
489
let p = parse ( MARKED ) .unwrap ( ) ;
490
let pick = | a : Choice, b : Choice| assemble ( & p, & HashMap:: from( [ ( 1 , a) , ( 2 , b) ] ) ) ;
491
assert_eq! ( pick ( Choice:: Side( 2 ) , Choice:: Side( 1 ) ) .unwrap ( ) , " a\n from-3\n more-3\n b\n c\n " , " the deletion wins: nothing" ) ;
492
assert_eq! ( pick ( Choice:: All, Choice:: Side( 2 ) ) .unwrap ( ) , " a\n from-2\n from-3\n more-3\n b\n added\n c\n " ) ;
493
assert_eq! ( pick ( Choice:: None, Choice:: Text( " mine" .into ( ) ) ) .unwrap ( ) , " a\n b\n mine\n c\n " , " own text ends its line" ) ;
494
assert! ( pick ( Choice:: Side( 3 ) , Choice:: None) .unwrap_err ( ) .contains ( " no side 3" ) ) ;
495
assert! ( assemble ( & p, & HashMap:: from( [ ( 1 , Choice:: None) ] ) ) .unwrap_err ( ) .contains ( " conflict 2" ) ) ;
496
}
497
}