Skip to main content

subversion/wc/
mod.rs

1//! Working copy management and status operations.
2//!
3//! This module provides low-level access to Subversion working copies through the [`Context`](crate::wc::Context) type.
4//! It handles working copy metadata, status tracking, and local modifications.
5//!
6//! # Overview
7//!
8//! The working copy (WC) layer manages the `.svn` administrative directory and tracks the state
9//! of files in a working copy. Most users should use the [`client`](crate::client) module instead,
10//! which provides higher-level operations. This module is useful for tools that need direct
11//! access to working copy internals.
12//!
13//! ## Key Operations
14//!
15//! - **Status tracking**: Walk working copy and report file status
16//! - **Property management**: Get, set, and list versioned properties
17//! - **Conflict resolution**: Handle and resolve merge conflicts
18//! - **Working copy maintenance**: Revert changes, cleanup locks
19//! - **Notification**: Receive callbacks about working copy operations
20//!
21//! # Example
22//!
23//! ```no_run
24//! use subversion::wc::Context;
25//!
26//! let ctx = Context::new().unwrap();
27//!
28//! // Check if a path is a working copy root
29//! if ctx.is_wc_root("/path/to/wc").unwrap() {
30//!     println!("This is a working copy root");
31//! }
32//! ```
33
34use crate::{svn_result, with_tmp_pool, Error};
35use std::marker::PhantomData;
36use subversion_sys::{svn_wc_context_t, svn_wc_version};
37
38// Helper functions for properly boxing callback batons
39// wrap_cancel_func expects *mut Box<dyn Fn()>, not *mut Box<&dyn Fn()>
40// We need double-boxing to avoid UB
41fn box_cancel_baton(f: Box<dyn Fn() -> Result<(), Error<'static>>>) -> *mut std::ffi::c_void {
42    Box::into_raw(Box::new(f)) as *mut std::ffi::c_void
43}
44
45fn box_notify_baton(f: Box<dyn Fn(&Notify)>) -> *mut std::ffi::c_void {
46    Box::into_raw(Box::new(f)) as *mut std::ffi::c_void
47}
48
49fn box_conflict_baton(
50    f: Box<
51        dyn Fn(
52            &crate::conflict::ConflictDescription,
53        ) -> Result<crate::conflict::ConflictResult, Error<'static>>,
54    >,
55) -> *mut std::ffi::c_void {
56    Box::into_raw(Box::new(f)) as *mut std::ffi::c_void
57}
58
59fn box_external_baton(
60    f: Box<dyn Fn(&str, Option<&str>, Option<&str>, crate::Depth) -> Result<(), Error<'static>>>,
61) -> *mut std::ffi::c_void {
62    Box::into_raw(Box::new(f)) as *mut std::ffi::c_void
63}
64
65fn box_fetch_dirents_baton(
66    f: Box<
67        dyn Fn(
68            &str,
69            &str,
70        )
71            -> Result<std::collections::HashMap<String, crate::DirEntry>, Error<'static>>,
72    >,
73) -> *mut std::ffi::c_void {
74    Box::into_raw(Box::new(f)) as *mut std::ffi::c_void
75}
76
77// Borrowed versions for synchronous operations where callback lifetime is guaranteed
78fn box_cancel_baton_borrowed(f: &dyn Fn() -> Result<(), Error<'static>>) -> *mut std::ffi::c_void {
79    Box::into_raw(Box::new(f)) as *mut std::ffi::c_void
80}
81
82pub(crate) fn box_notify_baton_borrowed(f: &dyn Fn(&Notify)) -> *mut std::ffi::c_void {
83    Box::into_raw(Box::new(f)) as *mut std::ffi::c_void
84}
85
86// Dropper functions for each callback type
87unsafe fn drop_cancel_baton(baton: *mut std::ffi::c_void) {
88    drop(Box::from_raw(
89        baton as *mut Box<dyn Fn() -> Result<(), Error<'static>>>,
90    ));
91}
92
93unsafe fn drop_notify_baton(baton: *mut std::ffi::c_void) {
94    drop(Box::from_raw(baton as *mut Box<dyn Fn(&Notify)>));
95}
96
97unsafe fn drop_conflict_baton(baton: *mut std::ffi::c_void) {
98    drop(Box::from_raw(
99        baton
100            as *mut Box<
101                dyn Fn(
102                    &crate::conflict::ConflictDescription,
103                ) -> Result<crate::conflict::ConflictResult, Error<'static>>,
104            >,
105    ));
106}
107
108unsafe fn drop_external_baton(baton: *mut std::ffi::c_void) {
109    drop(Box::from_raw(
110        baton
111            as *mut Box<
112                dyn Fn(
113                    &str,
114                    Option<&str>,
115                    Option<&str>,
116                    crate::Depth,
117                ) -> Result<(), Error<'static>>,
118            >,
119    ));
120}
121
122unsafe fn drop_fetch_dirents_baton(baton: *mut std::ffi::c_void) {
123    drop(Box::from_raw(
124        baton
125            as *mut Box<
126                dyn Fn(
127                    &str,
128                    &str,
129                ) -> Result<
130                    std::collections::HashMap<String, crate::DirEntry>,
131                    Error<'static>,
132                >,
133            >,
134    ));
135}
136
137// Dropper functions for borrowed callbacks (used in synchronous operations)
138unsafe fn drop_cancel_baton_borrowed(baton: *mut std::ffi::c_void) {
139    drop(Box::from_raw(
140        baton as *mut &dyn Fn() -> Result<(), Error<'static>>,
141    ));
142}
143
144pub(crate) unsafe fn drop_notify_baton_borrowed(baton: *mut std::ffi::c_void) {
145    drop(Box::from_raw(baton as *mut &dyn Fn(&Notify)));
146}
147
148/// Returns the version information for the working copy library.
149pub fn version() -> crate::Version {
150    unsafe { crate::Version(svn_wc_version()) }
151}
152
153// Status constants for Python compatibility
154/// Status constant indicating no status.
155pub const STATUS_NONE: u32 = subversion_sys::svn_wc_status_kind_svn_wc_status_none as u32;
156/// Status constant for unversioned items.
157pub const STATUS_UNVERSIONED: u32 =
158    subversion_sys::svn_wc_status_kind_svn_wc_status_unversioned as u32;
159/// Status constant for normal versioned items.
160pub const STATUS_NORMAL: u32 = subversion_sys::svn_wc_status_kind_svn_wc_status_normal as u32;
161/// Status constant for added items.
162pub const STATUS_ADDED: u32 = subversion_sys::svn_wc_status_kind_svn_wc_status_added as u32;
163/// Status constant for missing items.
164pub const STATUS_MISSING: u32 = subversion_sys::svn_wc_status_kind_svn_wc_status_missing as u32;
165/// Status constant for deleted items.
166pub const STATUS_DELETED: u32 = subversion_sys::svn_wc_status_kind_svn_wc_status_deleted as u32;
167/// Status constant for replaced items.
168pub const STATUS_REPLACED: u32 = subversion_sys::svn_wc_status_kind_svn_wc_status_replaced as u32;
169/// Status constant for modified items.
170pub const STATUS_MODIFIED: u32 = subversion_sys::svn_wc_status_kind_svn_wc_status_modified as u32;
171/// Status constant for merged items.
172pub const STATUS_MERGED: u32 = subversion_sys::svn_wc_status_kind_svn_wc_status_merged as u32;
173/// Status constant for conflicted items.
174pub const STATUS_CONFLICTED: u32 =
175    subversion_sys::svn_wc_status_kind_svn_wc_status_conflicted as u32;
176/// Status constant for ignored items.
177pub const STATUS_IGNORED: u32 = subversion_sys::svn_wc_status_kind_svn_wc_status_ignored as u32;
178/// Status constant for obstructed items.
179pub const STATUS_OBSTRUCTED: u32 =
180    subversion_sys::svn_wc_status_kind_svn_wc_status_obstructed as u32;
181/// Status constant for external items.
182pub const STATUS_EXTERNAL: u32 = subversion_sys::svn_wc_status_kind_svn_wc_status_external as u32;
183/// Status constant for incomplete items.
184pub const STATUS_INCOMPLETE: u32 =
185    subversion_sys::svn_wc_status_kind_svn_wc_status_incomplete as u32;
186
187// Schedule constants for Python compatibility
188/// Schedule constant for normal items.
189pub const SCHEDULE_NORMAL: u32 = subversion_sys::svn_wc_schedule_t_svn_wc_schedule_normal as u32;
190/// Schedule constant for items to be added.
191pub const SCHEDULE_ADD: u32 = subversion_sys::svn_wc_schedule_t_svn_wc_schedule_add as u32;
192/// Schedule constant for items to be deleted.
193pub const SCHEDULE_DELETE: u32 = subversion_sys::svn_wc_schedule_t_svn_wc_schedule_delete as u32;
194/// Schedule constant for items to be replaced.
195pub const SCHEDULE_REPLACE: u32 = subversion_sys::svn_wc_schedule_t_svn_wc_schedule_replace as u32;
196
197/// Working copy status types.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199#[repr(u32)]
200pub enum StatusKind {
201    /// Not under version control
202    None = subversion_sys::svn_wc_status_kind_svn_wc_status_none as u32,
203    /// Item is not versioned
204    Unversioned = subversion_sys::svn_wc_status_kind_svn_wc_status_unversioned as u32,
205    /// Item is versioned and unchanged
206    Normal = subversion_sys::svn_wc_status_kind_svn_wc_status_normal as u32,
207    /// Item has been added
208    Added = subversion_sys::svn_wc_status_kind_svn_wc_status_added as u32,
209    /// Item is missing (removed by non-svn command)
210    Missing = subversion_sys::svn_wc_status_kind_svn_wc_status_missing as u32,
211    /// Item has been deleted
212    Deleted = subversion_sys::svn_wc_status_kind_svn_wc_status_deleted as u32,
213    /// Item has been replaced
214    Replaced = subversion_sys::svn_wc_status_kind_svn_wc_status_replaced as u32,
215    /// Item has been modified
216    Modified = subversion_sys::svn_wc_status_kind_svn_wc_status_modified as u32,
217    /// Item has been merged
218    Merged = subversion_sys::svn_wc_status_kind_svn_wc_status_merged as u32,
219    /// Item is in conflict
220    Conflicted = subversion_sys::svn_wc_status_kind_svn_wc_status_conflicted as u32,
221    /// Item is ignored
222    Ignored = subversion_sys::svn_wc_status_kind_svn_wc_status_ignored as u32,
223    /// Item is obstructed
224    Obstructed = subversion_sys::svn_wc_status_kind_svn_wc_status_obstructed as u32,
225    /// Item is an external
226    External = subversion_sys::svn_wc_status_kind_svn_wc_status_external as u32,
227    /// Item is incomplete
228    Incomplete = subversion_sys::svn_wc_status_kind_svn_wc_status_incomplete as u32,
229}
230
231impl From<subversion_sys::svn_wc_status_kind> for StatusKind {
232    fn from(status: subversion_sys::svn_wc_status_kind) -> Self {
233        match status {
234            subversion_sys::svn_wc_status_kind_svn_wc_status_none => StatusKind::None,
235            subversion_sys::svn_wc_status_kind_svn_wc_status_unversioned => StatusKind::Unversioned,
236            subversion_sys::svn_wc_status_kind_svn_wc_status_normal => StatusKind::Normal,
237            subversion_sys::svn_wc_status_kind_svn_wc_status_added => StatusKind::Added,
238            subversion_sys::svn_wc_status_kind_svn_wc_status_missing => StatusKind::Missing,
239            subversion_sys::svn_wc_status_kind_svn_wc_status_deleted => StatusKind::Deleted,
240            subversion_sys::svn_wc_status_kind_svn_wc_status_replaced => StatusKind::Replaced,
241            subversion_sys::svn_wc_status_kind_svn_wc_status_modified => StatusKind::Modified,
242            subversion_sys::svn_wc_status_kind_svn_wc_status_merged => StatusKind::Merged,
243            subversion_sys::svn_wc_status_kind_svn_wc_status_conflicted => StatusKind::Conflicted,
244            subversion_sys::svn_wc_status_kind_svn_wc_status_ignored => StatusKind::Ignored,
245            subversion_sys::svn_wc_status_kind_svn_wc_status_obstructed => StatusKind::Obstructed,
246            subversion_sys::svn_wc_status_kind_svn_wc_status_external => StatusKind::External,
247            subversion_sys::svn_wc_status_kind_svn_wc_status_incomplete => StatusKind::Incomplete,
248            _ => unreachable!("unknown svn_wc_status_kind value: {}", status),
249        }
250    }
251}
252
253/// Represents a property change in the working copy
254///
255/// A property change consists of a property name and an optional value.
256/// If the value is None, it indicates the property has been deleted.
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct PropChange {
259    /// The name of the property
260    pub name: String,
261    /// The new value of the property, or None if deleted
262    pub value: Option<Vec<u8>>,
263}
264
265/// Working copy status information
266pub struct Status<'pool> {
267    ptr: *const subversion_sys::svn_wc_status3_t,
268    /// Keeps the APR pool that owns `ptr`'s allocation alive.
269    /// `PoolHandle::Owned` when `Status` is returned from `Context::status()`;
270    /// `PoolHandle::Borrowed` (non-destroying) when created inside a callback
271    /// whose pool is managed by the SVN C library.
272    _pool: apr::pool::PoolHandle<'pool>,
273}
274
275impl<'pool> Status<'pool> {
276    /// Get the node status
277    pub fn node_status(&self) -> StatusKind {
278        unsafe { (*self.ptr).node_status.into() }
279    }
280
281    /// Get the text status
282    pub fn text_status(&self) -> StatusKind {
283        unsafe { (*self.ptr).text_status.into() }
284    }
285
286    /// Get the property status
287    pub fn prop_status(&self) -> StatusKind {
288        unsafe { (*self.ptr).prop_status.into() }
289    }
290
291    /// Check if the item is copied
292    pub fn copied(&self) -> bool {
293        unsafe { (*self.ptr).copied != 0 }
294    }
295
296    /// Check if the item is switched
297    pub fn switched(&self) -> bool {
298        unsafe { (*self.ptr).switched != 0 }
299    }
300
301    /// Check if the item is locked
302    pub fn locked(&self) -> bool {
303        unsafe { (*self.ptr).locked != 0 }
304    }
305
306    /// Get the revision
307    pub fn revision(&self) -> crate::Revnum {
308        unsafe { crate::Revnum((*self.ptr).revision) }
309    }
310
311    /// Get the changed revision
312    pub fn changed_rev(&self) -> crate::Revnum {
313        unsafe { crate::Revnum((*self.ptr).changed_rev) }
314    }
315
316    /// Get the repository relative path
317    pub fn repos_relpath(&self) -> Option<String> {
318        unsafe {
319            if (*self.ptr).repos_relpath.is_null() {
320                None
321            } else {
322                Some(
323                    std::ffi::CStr::from_ptr((*self.ptr).repos_relpath)
324                        .to_string_lossy()
325                        .into_owned(),
326                )
327            }
328        }
329    }
330
331    /// Get the node kind
332    pub fn kind(&self) -> i32 {
333        unsafe { (*self.ptr).kind as i32 }
334    }
335
336    /// Get the depth
337    pub fn depth(&self) -> i32 {
338        unsafe { (*self.ptr).depth }
339    }
340
341    /// Get the file size
342    pub fn filesize(&self) -> i64 {
343        unsafe { (*self.ptr).filesize }
344    }
345
346    /// Check if the item is versioned
347    pub fn versioned(&self) -> bool {
348        unsafe { (*self.ptr).versioned != 0 }
349    }
350
351    /// Get the repository UUID
352    pub fn repos_uuid(&self) -> Option<String> {
353        unsafe {
354            if (*self.ptr).repos_uuid.is_null() {
355                None
356            } else {
357                Some(
358                    std::ffi::CStr::from_ptr((*self.ptr).repos_uuid)
359                        .to_string_lossy()
360                        .into_owned(),
361                )
362            }
363        }
364    }
365
366    /// Get the repository root URL
367    pub fn repos_root_url(&self) -> Option<String> {
368        unsafe {
369            if (*self.ptr).repos_root_url.is_null() {
370                None
371            } else {
372                Some(
373                    std::ffi::CStr::from_ptr((*self.ptr).repos_root_url)
374                        .to_string_lossy()
375                        .into_owned(),
376                )
377            }
378        }
379    }
380
381    /// Duplicate this status into a new owned pool, producing a `Status<'static>`.
382    pub fn dup(&self) -> Status<'static> {
383        let pool = apr::pool::Pool::new();
384        let ptr = unsafe { subversion_sys::svn_wc_dup_status3(self.ptr, pool.as_mut_ptr()) };
385        Status {
386            ptr,
387            _pool: apr::pool::PoolHandle::Owned(pool),
388        }
389    }
390}
391
392/// Working copy schedule types
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394#[repr(u32)]
395pub enum Schedule {
396    /// Nothing scheduled
397    Normal = subversion_sys::svn_wc_schedule_t_svn_wc_schedule_normal as u32,
398    /// Scheduled for addition
399    Add = subversion_sys::svn_wc_schedule_t_svn_wc_schedule_add as u32,
400    /// Scheduled for deletion
401    Delete = subversion_sys::svn_wc_schedule_t_svn_wc_schedule_delete as u32,
402    /// Scheduled for replacement
403    Replace = subversion_sys::svn_wc_schedule_t_svn_wc_schedule_replace as u32,
404}
405
406impl From<subversion_sys::svn_wc_schedule_t> for Schedule {
407    fn from(schedule: subversion_sys::svn_wc_schedule_t) -> Self {
408        match schedule {
409            subversion_sys::svn_wc_schedule_t_svn_wc_schedule_normal => Schedule::Normal,
410            subversion_sys::svn_wc_schedule_t_svn_wc_schedule_add => Schedule::Add,
411            subversion_sys::svn_wc_schedule_t_svn_wc_schedule_delete => Schedule::Delete,
412            subversion_sys::svn_wc_schedule_t_svn_wc_schedule_replace => Schedule::Replace,
413            _ => unreachable!("unknown svn_wc_schedule_t value: {}", schedule),
414        }
415    }
416}
417
418/// Outcome of a file merge operation.
419#[derive(Debug, Clone, Copy, PartialEq, Eq)]
420pub enum MergeOutcome {
421    /// The working copy is (or would be) unchanged; changes were already present.
422    Unchanged,
423    /// The working copy has been (or would be) changed.
424    Merged,
425    /// The working copy has been (or would be) changed, but with a conflict.
426    Conflict,
427    /// No merge was performed (target absent or unversioned).
428    NoMerge,
429}
430
431impl From<subversion_sys::svn_wc_merge_outcome_t> for MergeOutcome {
432    fn from(outcome: subversion_sys::svn_wc_merge_outcome_t) -> Self {
433        match outcome {
434            subversion_sys::svn_wc_merge_outcome_t_svn_wc_merge_unchanged => {
435                MergeOutcome::Unchanged
436            }
437            subversion_sys::svn_wc_merge_outcome_t_svn_wc_merge_merged => MergeOutcome::Merged,
438            subversion_sys::svn_wc_merge_outcome_t_svn_wc_merge_conflict => MergeOutcome::Conflict,
439            subversion_sys::svn_wc_merge_outcome_t_svn_wc_merge_no_merge => MergeOutcome::NoMerge,
440            _ => unreachable!("unknown svn_wc_merge_outcome_t value: {}", outcome),
441        }
442    }
443}
444
445/// State of a working copy item after a notify operation.
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub enum NotifyState {
448    /// Not applicable.
449    Inapplicable,
450    /// State is unknown.
451    Unknown,
452    /// Item was unchanged.
453    Unchanged,
454    /// Item was missing.
455    Missing,
456    /// An unversioned item obstructed the operation.
457    Obstructed,
458    /// Item was changed.
459    Changed,
460    /// Item had modifications merged in.
461    Merged,
462    /// Item got conflicting modifications.
463    Conflicted,
464    /// The source for a copy was missing.
465    SourceMissing,
466}
467
468impl From<subversion_sys::svn_wc_notify_state_t> for NotifyState {
469    fn from(state: subversion_sys::svn_wc_notify_state_t) -> Self {
470        match state {
471            subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_inapplicable => {
472                NotifyState::Inapplicable
473            }
474            subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_unknown => {
475                NotifyState::Unknown
476            }
477            subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_unchanged => {
478                NotifyState::Unchanged
479            }
480            subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_missing => {
481                NotifyState::Missing
482            }
483            subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_obstructed => {
484                NotifyState::Obstructed
485            }
486            subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_changed => {
487                NotifyState::Changed
488            }
489            subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_merged => NotifyState::Merged,
490            subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_conflicted => {
491                NotifyState::Conflicted
492            }
493            subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_source_missing => {
494                NotifyState::SourceMissing
495            }
496            _ => unreachable!("unknown svn_wc_notify_state_t value: {}", state),
497        }
498    }
499}
500
501impl From<NotifyState> for subversion_sys::svn_wc_notify_state_t {
502    fn from(state: NotifyState) -> Self {
503        match state {
504            NotifyState::Inapplicable => {
505                subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_inapplicable
506            }
507            NotifyState::Unknown => {
508                subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_unknown
509            }
510            NotifyState::Unchanged => {
511                subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_unchanged
512            }
513            NotifyState::Missing => {
514                subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_missing
515            }
516            NotifyState::Obstructed => {
517                subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_obstructed
518            }
519            NotifyState::Changed => {
520                subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_changed
521            }
522            NotifyState::Merged => subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_merged,
523            NotifyState::Conflicted => {
524                subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_conflicted
525            }
526            NotifyState::SourceMissing => {
527                subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_source_missing
528            }
529        }
530    }
531}
532
533/// A file change event reported by the diff callbacks.
534pub struct FileChange<'a> {
535    /// Relative path of the file within the working copy.
536    pub path: &'a str,
537    /// Temporary file with the "left" (base) content, if changed.
538    pub tmpfile1: Option<&'a str>,
539    /// Temporary file with the "right" (modified) content, if changed.
540    pub tmpfile2: Option<&'a str>,
541    /// Revision of the left side.
542    pub rev1: crate::Revnum,
543    /// Revision of the right side.
544    pub rev2: crate::Revnum,
545    /// MIME type of the left side, if known.
546    pub mimetype1: Option<&'a str>,
547    /// MIME type of the right side, if known.
548    pub mimetype2: Option<&'a str>,
549    /// Property changes for this file.
550    pub prop_changes: Vec<PropChange>,
551}
552
553// --- Diff callback trampolines (module-level so they can be shared) ---
554
555/// Convert a raw apr_array_header_t of svn_prop_t into Vec<PropChange>.
556unsafe fn diff_prop_array_to_vec(arr: *const apr_sys::apr_array_header_t) -> Vec<PropChange> {
557    if arr.is_null() {
558        return Vec::new();
559    }
560    let typed = apr::tables::TypedArray::<subversion_sys::svn_prop_t>::from_ptr(
561        arr as *mut apr_sys::apr_array_header_t,
562    );
563    typed
564        .iter()
565        .map(|p| {
566            let name = if p.name.is_null() {
567                String::new()
568            } else {
569                std::ffi::CStr::from_ptr(p.name)
570                    .to_string_lossy()
571                    .into_owned()
572            };
573            let value = if p.value.is_null() {
574                None
575            } else {
576                Some(crate::svn_string_helpers::to_vec(&*p.value))
577            };
578            PropChange { name, value }
579        })
580        .collect()
581}
582
583/// Turn a nullable C string into Option<&str>.
584unsafe fn diff_opt_str<'a>(p: *const std::os::raw::c_char) -> Option<&'a str> {
585    if p.is_null() {
586        None
587    } else {
588        std::ffi::CStr::from_ptr(p).to_str().ok()
589    }
590}
591
592unsafe extern "C" fn diff_cb_file_opened(
593    tree_conflicted: *mut subversion_sys::svn_boolean_t,
594    skip: *mut subversion_sys::svn_boolean_t,
595    path: *const std::os::raw::c_char,
596    rev: subversion_sys::svn_revnum_t,
597    diff_baton: *mut std::ffi::c_void,
598    _scratch_pool: *mut apr_sys::apr_pool_t,
599) -> *mut subversion_sys::svn_error_t {
600    let cb = &mut *(diff_baton as *mut &mut dyn DiffCallbacks);
601    let path = std::ffi::CStr::from_ptr(path).to_str().unwrap_or("");
602    match cb.file_opened(path, crate::Revnum(rev)) {
603        Ok((tc, sk)) => {
604            if !tree_conflicted.is_null() {
605                *tree_conflicted = tc as i32;
606            }
607            if !skip.is_null() {
608                *skip = sk as i32;
609            }
610            std::ptr::null_mut()
611        }
612        Err(e) => e.into_raw(),
613    }
614}
615
616unsafe extern "C" fn diff_cb_file_changed(
617    contentstate: *mut subversion_sys::svn_wc_notify_state_t,
618    propstate: *mut subversion_sys::svn_wc_notify_state_t,
619    tree_conflicted: *mut subversion_sys::svn_boolean_t,
620    path: *const std::os::raw::c_char,
621    tmpfile1: *const std::os::raw::c_char,
622    tmpfile2: *const std::os::raw::c_char,
623    rev1: subversion_sys::svn_revnum_t,
624    rev2: subversion_sys::svn_revnum_t,
625    mimetype1: *const std::os::raw::c_char,
626    mimetype2: *const std::os::raw::c_char,
627    propchanges: *const apr_sys::apr_array_header_t,
628    _originalprops: *mut apr_sys::apr_hash_t,
629    diff_baton: *mut std::ffi::c_void,
630    _scratch_pool: *mut apr_sys::apr_pool_t,
631) -> *mut subversion_sys::svn_error_t {
632    let cb = &mut *(diff_baton as *mut &mut dyn DiffCallbacks);
633    let change = FileChange {
634        path: std::ffi::CStr::from_ptr(path).to_str().unwrap_or(""),
635        tmpfile1: diff_opt_str(tmpfile1),
636        tmpfile2: diff_opt_str(tmpfile2),
637        rev1: crate::Revnum(rev1),
638        rev2: crate::Revnum(rev2),
639        mimetype1: diff_opt_str(mimetype1),
640        mimetype2: diff_opt_str(mimetype2),
641        prop_changes: diff_prop_array_to_vec(propchanges),
642    };
643    match cb.file_changed(&change) {
644        Ok((cs, ps, tc)) => {
645            if !contentstate.is_null() {
646                *contentstate = cs.into();
647            }
648            if !propstate.is_null() {
649                *propstate = ps.into();
650            }
651            if !tree_conflicted.is_null() {
652                *tree_conflicted = tc as i32;
653            }
654            std::ptr::null_mut()
655        }
656        Err(e) => e.into_raw(),
657    }
658}
659
660unsafe extern "C" fn diff_cb_file_added(
661    contentstate: *mut subversion_sys::svn_wc_notify_state_t,
662    propstate: *mut subversion_sys::svn_wc_notify_state_t,
663    tree_conflicted: *mut subversion_sys::svn_boolean_t,
664    path: *const std::os::raw::c_char,
665    tmpfile1: *const std::os::raw::c_char,
666    tmpfile2: *const std::os::raw::c_char,
667    rev1: subversion_sys::svn_revnum_t,
668    rev2: subversion_sys::svn_revnum_t,
669    mimetype1: *const std::os::raw::c_char,
670    mimetype2: *const std::os::raw::c_char,
671    copyfrom_path: *const std::os::raw::c_char,
672    copyfrom_revision: subversion_sys::svn_revnum_t,
673    propchanges: *const apr_sys::apr_array_header_t,
674    _originalprops: *mut apr_sys::apr_hash_t,
675    diff_baton: *mut std::ffi::c_void,
676    _scratch_pool: *mut apr_sys::apr_pool_t,
677) -> *mut subversion_sys::svn_error_t {
678    let cb = &mut *(diff_baton as *mut &mut dyn DiffCallbacks);
679    let change = FileChange {
680        path: std::ffi::CStr::from_ptr(path).to_str().unwrap_or(""),
681        tmpfile1: diff_opt_str(tmpfile1),
682        tmpfile2: diff_opt_str(tmpfile2),
683        rev1: crate::Revnum(rev1),
684        rev2: crate::Revnum(rev2),
685        mimetype1: diff_opt_str(mimetype1),
686        mimetype2: diff_opt_str(mimetype2),
687        prop_changes: diff_prop_array_to_vec(propchanges),
688    };
689    match cb.file_added(
690        &change,
691        diff_opt_str(copyfrom_path),
692        crate::Revnum(copyfrom_revision),
693    ) {
694        Ok((cs, ps, tc)) => {
695            if !contentstate.is_null() {
696                *contentstate = cs.into();
697            }
698            if !propstate.is_null() {
699                *propstate = ps.into();
700            }
701            if !tree_conflicted.is_null() {
702                *tree_conflicted = tc as i32;
703            }
704            std::ptr::null_mut()
705        }
706        Err(e) => e.into_raw(),
707    }
708}
709
710unsafe extern "C" fn diff_cb_file_deleted(
711    state: *mut subversion_sys::svn_wc_notify_state_t,
712    tree_conflicted: *mut subversion_sys::svn_boolean_t,
713    path: *const std::os::raw::c_char,
714    tmpfile1: *const std::os::raw::c_char,
715    tmpfile2: *const std::os::raw::c_char,
716    mimetype1: *const std::os::raw::c_char,
717    mimetype2: *const std::os::raw::c_char,
718    _originalprops: *mut apr_sys::apr_hash_t,
719    diff_baton: *mut std::ffi::c_void,
720    _scratch_pool: *mut apr_sys::apr_pool_t,
721) -> *mut subversion_sys::svn_error_t {
722    let cb = &mut *(diff_baton as *mut &mut dyn DiffCallbacks);
723    let path = std::ffi::CStr::from_ptr(path).to_str().unwrap_or("");
724    match cb.file_deleted(
725        path,
726        diff_opt_str(tmpfile1),
727        diff_opt_str(tmpfile2),
728        diff_opt_str(mimetype1),
729        diff_opt_str(mimetype2),
730    ) {
731        Ok((st, tc)) => {
732            if !state.is_null() {
733                *state = st.into();
734            }
735            if !tree_conflicted.is_null() {
736                *tree_conflicted = tc as i32;
737            }
738            std::ptr::null_mut()
739        }
740        Err(e) => e.into_raw(),
741    }
742}
743
744unsafe extern "C" fn diff_cb_dir_deleted(
745    state: *mut subversion_sys::svn_wc_notify_state_t,
746    tree_conflicted: *mut subversion_sys::svn_boolean_t,
747    path: *const std::os::raw::c_char,
748    diff_baton: *mut std::ffi::c_void,
749    _scratch_pool: *mut apr_sys::apr_pool_t,
750) -> *mut subversion_sys::svn_error_t {
751    let cb = &mut *(diff_baton as *mut &mut dyn DiffCallbacks);
752    let path = std::ffi::CStr::from_ptr(path).to_str().unwrap_or("");
753    match cb.dir_deleted(path) {
754        Ok((st, tc)) => {
755            if !state.is_null() {
756                *state = st.into();
757            }
758            if !tree_conflicted.is_null() {
759                *tree_conflicted = tc as i32;
760            }
761            std::ptr::null_mut()
762        }
763        Err(e) => e.into_raw(),
764    }
765}
766
767unsafe extern "C" fn diff_cb_dir_opened(
768    tree_conflicted: *mut subversion_sys::svn_boolean_t,
769    skip: *mut subversion_sys::svn_boolean_t,
770    skip_children: *mut subversion_sys::svn_boolean_t,
771    path: *const std::os::raw::c_char,
772    rev: subversion_sys::svn_revnum_t,
773    diff_baton: *mut std::ffi::c_void,
774    _scratch_pool: *mut apr_sys::apr_pool_t,
775) -> *mut subversion_sys::svn_error_t {
776    let cb = &mut *(diff_baton as *mut &mut dyn DiffCallbacks);
777    let path = std::ffi::CStr::from_ptr(path).to_str().unwrap_or("");
778    match cb.dir_opened(path, crate::Revnum(rev)) {
779        Ok((tc, sk, skc)) => {
780            if !tree_conflicted.is_null() {
781                *tree_conflicted = tc as i32;
782            }
783            if !skip.is_null() {
784                *skip = sk as i32;
785            }
786            if !skip_children.is_null() {
787                *skip_children = skc as i32;
788            }
789            std::ptr::null_mut()
790        }
791        Err(e) => e.into_raw(),
792    }
793}
794
795unsafe extern "C" fn diff_cb_dir_added(
796    state: *mut subversion_sys::svn_wc_notify_state_t,
797    tree_conflicted: *mut subversion_sys::svn_boolean_t,
798    skip: *mut subversion_sys::svn_boolean_t,
799    skip_children: *mut subversion_sys::svn_boolean_t,
800    path: *const std::os::raw::c_char,
801    rev: subversion_sys::svn_revnum_t,
802    copyfrom_path: *const std::os::raw::c_char,
803    copyfrom_revision: subversion_sys::svn_revnum_t,
804    diff_baton: *mut std::ffi::c_void,
805    _scratch_pool: *mut apr_sys::apr_pool_t,
806) -> *mut subversion_sys::svn_error_t {
807    let cb = &mut *(diff_baton as *mut &mut dyn DiffCallbacks);
808    let path = std::ffi::CStr::from_ptr(path).to_str().unwrap_or("");
809    match cb.dir_added(
810        path,
811        crate::Revnum(rev),
812        diff_opt_str(copyfrom_path),
813        crate::Revnum(copyfrom_revision),
814    ) {
815        Ok((st, tc, sk, skc)) => {
816            if !state.is_null() {
817                *state = st.into();
818            }
819            if !tree_conflicted.is_null() {
820                *tree_conflicted = tc as i32;
821            }
822            if !skip.is_null() {
823                *skip = sk as i32;
824            }
825            if !skip_children.is_null() {
826                *skip_children = skc as i32;
827            }
828            std::ptr::null_mut()
829        }
830        Err(e) => e.into_raw(),
831    }
832}
833
834unsafe extern "C" fn diff_cb_dir_props_changed(
835    propstate: *mut subversion_sys::svn_wc_notify_state_t,
836    tree_conflicted: *mut subversion_sys::svn_boolean_t,
837    path: *const std::os::raw::c_char,
838    dir_was_added: subversion_sys::svn_boolean_t,
839    propchanges: *const apr_sys::apr_array_header_t,
840    _original_props: *mut apr_sys::apr_hash_t,
841    diff_baton: *mut std::ffi::c_void,
842    _scratch_pool: *mut apr_sys::apr_pool_t,
843) -> *mut subversion_sys::svn_error_t {
844    let cb = &mut *(diff_baton as *mut &mut dyn DiffCallbacks);
845    let path = std::ffi::CStr::from_ptr(path).to_str().unwrap_or("");
846    let changes = diff_prop_array_to_vec(propchanges);
847    match cb.dir_props_changed(path, dir_was_added != 0, &changes) {
848        Ok((ps, tc)) => {
849            if !propstate.is_null() {
850                *propstate = ps.into();
851            }
852            if !tree_conflicted.is_null() {
853                *tree_conflicted = tc as i32;
854            }
855            std::ptr::null_mut()
856        }
857        Err(e) => e.into_raw(),
858    }
859}
860
861unsafe extern "C" fn diff_cb_dir_closed(
862    contentstate: *mut subversion_sys::svn_wc_notify_state_t,
863    propstate: *mut subversion_sys::svn_wc_notify_state_t,
864    tree_conflicted: *mut subversion_sys::svn_boolean_t,
865    path: *const std::os::raw::c_char,
866    dir_was_added: subversion_sys::svn_boolean_t,
867    diff_baton: *mut std::ffi::c_void,
868    _scratch_pool: *mut apr_sys::apr_pool_t,
869) -> *mut subversion_sys::svn_error_t {
870    let cb = &mut *(diff_baton as *mut &mut dyn DiffCallbacks);
871    let path = std::ffi::CStr::from_ptr(path).to_str().unwrap_or("");
872    match cb.dir_closed(path, dir_was_added != 0) {
873        Ok((cs, ps, tc)) => {
874            if !contentstate.is_null() {
875                *contentstate = cs.into();
876            }
877            if !propstate.is_null() {
878                *propstate = ps.into();
879            }
880            if !tree_conflicted.is_null() {
881                *tree_conflicted = tc as i32;
882            }
883            std::ptr::null_mut()
884        }
885        Err(e) => e.into_raw(),
886    }
887}
888
889/// Build a `svn_wc_diff_callbacks4_t` struct pointing at the module-level trampolines.
890fn make_diff_callbacks4() -> subversion_sys::svn_wc_diff_callbacks4_t {
891    subversion_sys::svn_wc_diff_callbacks4_t {
892        file_opened: Some(diff_cb_file_opened),
893        file_changed: Some(diff_cb_file_changed),
894        file_added: Some(diff_cb_file_added),
895        file_deleted: Some(diff_cb_file_deleted),
896        dir_deleted: Some(diff_cb_dir_deleted),
897        dir_opened: Some(diff_cb_dir_opened),
898        dir_added: Some(diff_cb_dir_added),
899        dir_props_changed: Some(diff_cb_dir_props_changed),
900        dir_closed: Some(diff_cb_dir_closed),
901    }
902}
903
904/// Trait for receiving diff output from [`Context::diff`].
905///
906/// Implement this trait to receive file-level diff events when comparing a
907/// working copy path against its base revision.
908pub trait DiffCallbacks {
909    /// Called before `file_changed` to allow skipping expensive processing.
910    ///
911    /// Return `(tree_conflicted, skip)`.  Set `skip` to `true` to skip the
912    /// subsequent `file_changed` call for this path.
913    fn file_opened(
914        &mut self,
915        path: &str,
916        rev: crate::Revnum,
917    ) -> Result<(bool, bool), crate::Error<'static>>;
918
919    /// A file was modified.
920    ///
921    /// Return `(content_state, prop_state, tree_conflicted)`.
922    fn file_changed(
923        &mut self,
924        change: &FileChange<'_>,
925    ) -> Result<(NotifyState, NotifyState, bool), crate::Error<'static>>;
926
927    /// A file was added (or copied).
928    ///
929    /// `copyfrom_path` and `copyfrom_revision` are `Some` when the add is
930    /// actually a copy with history.
931    ///
932    /// Return `(content_state, prop_state, tree_conflicted)`.
933    fn file_added(
934        &mut self,
935        change: &FileChange<'_>,
936        copyfrom_path: Option<&str>,
937        copyfrom_revision: crate::Revnum,
938    ) -> Result<(NotifyState, NotifyState, bool), crate::Error<'static>>;
939
940    /// A file was deleted.
941    ///
942    /// Return `(state, tree_conflicted)`.
943    fn file_deleted(
944        &mut self,
945        path: &str,
946        tmpfile1: Option<&str>,
947        tmpfile2: Option<&str>,
948        mimetype1: Option<&str>,
949        mimetype2: Option<&str>,
950    ) -> Result<(NotifyState, bool), crate::Error<'static>>;
951
952    /// A directory was deleted.
953    ///
954    /// Return `(state, tree_conflicted)`.
955    fn dir_deleted(&mut self, path: &str) -> Result<(NotifyState, bool), crate::Error<'static>>;
956
957    /// A directory has been opened.
958    ///
959    /// Called before any callbacks for children of `path`.
960    /// Return `(tree_conflicted, skip, skip_children)`.
961    fn dir_opened(
962        &mut self,
963        path: &str,
964        rev: crate::Revnum,
965    ) -> Result<(bool, bool, bool), crate::Error<'static>>;
966
967    /// A directory was added (or copied).
968    ///
969    /// Return `(state, tree_conflicted, skip, skip_children)`.
970    fn dir_added(
971        &mut self,
972        path: &str,
973        rev: crate::Revnum,
974        copyfrom_path: Option<&str>,
975        copyfrom_revision: crate::Revnum,
976    ) -> Result<(NotifyState, bool, bool, bool), crate::Error<'static>>;
977
978    /// Property changes on a directory were applied.
979    ///
980    /// Return `(prop_state, tree_conflicted)`.
981    fn dir_props_changed(
982        &mut self,
983        path: &str,
984        dir_was_added: bool,
985        prop_changes: &[PropChange],
986    ) -> Result<(NotifyState, bool), crate::Error<'static>>;
987
988    /// A directory that was opened with `dir_opened` or `dir_added` has been closed.
989    ///
990    /// Return `(content_state, prop_state, tree_conflicted)`.
991    fn dir_closed(
992        &mut self,
993        path: &str,
994        dir_was_added: bool,
995    ) -> Result<(NotifyState, NotifyState, bool), crate::Error<'static>>;
996}
997
998/// Options for [`Context::diff`].
999pub struct DiffOptions {
1000    /// How deeply to traverse the working copy tree.
1001    pub depth: crate::Depth,
1002    /// If true, items with the same ancestry are not compared.
1003    pub ignore_ancestry: bool,
1004    /// If true, copies are shown as plain additions.
1005    pub show_copies_as_adds: bool,
1006    /// If true, produce git-compatible diff output.
1007    pub use_git_diff_format: bool,
1008    /// Only report items in these changelists (empty = all).
1009    pub changelists: Vec<String>,
1010}
1011
1012impl Default for DiffOptions {
1013    fn default() -> Self {
1014        Self {
1015            depth: crate::Depth::Infinity,
1016            ignore_ancestry: false,
1017            show_copies_as_adds: false,
1018            use_git_diff_format: false,
1019            changelists: Vec::new(),
1020        }
1021    }
1022}
1023
1024/// Options for [`Context::merge`].
1025#[derive(Default)]
1026pub struct MergeOptions {
1027    /// If true, perform a dry run without modifying the working copy.
1028    pub dry_run: bool,
1029    /// Path to an external diff3 command, or None to use the built-in.
1030    pub diff3_cmd: Option<String>,
1031    /// Extra options passed to the diff3 command.
1032    pub merge_options: Vec<String>,
1033}
1034
1035/// Options for [`Context::revert`].
1036pub struct RevertOptions {
1037    /// How deeply to revert below the target path.
1038    pub depth: crate::Depth,
1039    /// If `true`, set reverted files' timestamps to their last-commit time.
1040    /// If `false`, touch them with the current time.
1041    pub use_commit_times: bool,
1042    /// Only revert items in these changelists (empty = all items).
1043    pub changelists: Vec<String>,
1044    /// If `true`, also clear changelist membership on reverted items.
1045    pub clear_changelists: bool,
1046    /// If `true`, only revert metadata (e.g., remove conflict markers)
1047    /// without touching working-copy file content.
1048    pub metadata_only: bool,
1049    /// If `true`, items that were *added* (not copied) are kept on disk
1050    /// after being un-scheduled; otherwise they are deleted.
1051    pub added_keep_local: bool,
1052}
1053
1054impl Default for RevertOptions {
1055    fn default() -> Self {
1056        Self {
1057            depth: crate::Depth::Empty,
1058            use_commit_times: false,
1059            changelists: Vec::new(),
1060            clear_changelists: false,
1061            metadata_only: false,
1062            added_keep_local: true,
1063        }
1064    }
1065}
1066
1067/// Working copy context with RAII cleanup
1068pub struct Context {
1069    ptr: *mut svn_wc_context_t,
1070    pool: apr::Pool<'static>,
1071    _phantom: PhantomData<*mut ()>, // !Send + !Sync
1072}
1073
1074impl Context {
1075    /// Explicitly destroy the context, releasing all resources and locks.
1076    ///
1077    /// After calling this, the context is no longer usable.
1078    /// This is safe to call multiple times.
1079    pub fn close(&mut self) {
1080        if !self.ptr.is_null() {
1081            unsafe {
1082                subversion_sys::svn_wc_context_destroy(self.ptr);
1083            }
1084            self.ptr = std::ptr::null_mut();
1085        }
1086    }
1087}
1088
1089impl Drop for Context {
1090    fn drop(&mut self) {
1091        self.close();
1092    }
1093}
1094
1095pub mod adm;
1096#[allow(deprecated)]
1097pub use adm::Adm;
1098
1099impl Context {
1100    /// Get a reference to the underlying pool
1101    pub fn pool(&self) -> &apr::Pool<'_> {
1102        &self.pool
1103    }
1104
1105    /// Get the raw pointer to the context (use with caution)
1106    pub fn as_ptr(&self) -> *const svn_wc_context_t {
1107        self.ptr
1108    }
1109
1110    /// Get the mutable raw pointer to the context (use with caution)
1111    pub fn as_mut_ptr(&mut self) -> *mut svn_wc_context_t {
1112        self.ptr
1113    }
1114
1115    /// Creates a new working copy context.
1116    pub fn new() -> Result<Self, crate::Error<'static>> {
1117        let pool = apr::Pool::new();
1118
1119        unsafe {
1120            let mut ctx = std::ptr::null_mut();
1121            with_tmp_pool(|scratch_pool| {
1122                let err = subversion_sys::svn_wc_context_create(
1123                    &mut ctx,
1124                    std::ptr::null_mut(),
1125                    pool.as_mut_ptr(),
1126                    scratch_pool.as_mut_ptr(),
1127                );
1128                svn_result(err)
1129            })?;
1130
1131            Ok(Context {
1132                ptr: ctx,
1133                pool,
1134                _phantom: PhantomData,
1135            })
1136        }
1137    }
1138
1139    /// Create new context with configuration
1140    pub fn new_with_config(config: *mut std::ffi::c_void) -> Result<Self, crate::Error<'static>> {
1141        let pool = apr::Pool::new();
1142
1143        unsafe {
1144            let mut ctx = std::ptr::null_mut();
1145            with_tmp_pool(|scratch_pool| {
1146                let err = subversion_sys::svn_wc_context_create(
1147                    &mut ctx,
1148                    config as *mut subversion_sys::svn_config_t,
1149                    pool.as_mut_ptr(),
1150                    scratch_pool.as_mut_ptr(),
1151                );
1152                svn_result(err)
1153            })?;
1154
1155            Ok(Context {
1156                ptr: ctx,
1157                pool,
1158                _phantom: PhantomData,
1159            })
1160        }
1161    }
1162
1163    /// Checks the working copy format version.
1164    pub fn check_wc(&mut self, path: &str) -> Result<i32, crate::Error<'_>> {
1165        let scratch_pool = apr::pool::Pool::new();
1166        let path = crate::dirent::to_absolute_cstring(path)?;
1167        let mut wc_format = 0;
1168        let err = unsafe {
1169            subversion_sys::svn_wc_check_wc2(
1170                &mut wc_format,
1171                self.ptr,
1172                path.as_ptr(),
1173                scratch_pool.as_mut_ptr(),
1174            )
1175        };
1176        Error::from_raw(err)?;
1177        Ok(wc_format)
1178    }
1179
1180    /// Checks if a file's text content has been modified.
1181    pub fn text_modified(&mut self, path: &str) -> Result<bool, crate::Error<'_>> {
1182        let scratch_pool = apr::pool::Pool::new();
1183        let path = crate::dirent::to_absolute_cstring(path)?;
1184        let mut modified = 0;
1185        let err = unsafe {
1186            subversion_sys::svn_wc_text_modified_p2(
1187                &mut modified,
1188                self.ptr,
1189                path.as_ptr(),
1190                0,
1191                scratch_pool.as_mut_ptr(),
1192            )
1193        };
1194        Error::from_raw(err)?;
1195        Ok(modified != 0)
1196    }
1197
1198    /// Checks if a file's properties have been modified.
1199    pub fn props_modified(&mut self, path: &str) -> Result<bool, crate::Error<'_>> {
1200        let scratch_pool = apr::pool::Pool::new();
1201        let path = crate::dirent::to_absolute_cstring(path)?;
1202        let mut modified = 0;
1203        let err = unsafe {
1204            subversion_sys::svn_wc_props_modified_p2(
1205                &mut modified,
1206                self.ptr,
1207                path.as_ptr(),
1208                scratch_pool.as_mut_ptr(),
1209            )
1210        };
1211        Error::from_raw(err)?;
1212        Ok(modified != 0)
1213    }
1214
1215    /// Checks if a path has conflicts (text, property, tree).
1216    pub fn conflicted(&mut self, path: &str) -> Result<(bool, bool, bool), crate::Error<'_>> {
1217        let scratch_pool = apr::pool::Pool::new();
1218        let path = crate::dirent::to_absolute_cstring(path)?;
1219        let mut text_conflicted = 0;
1220        let mut prop_conflicted = 0;
1221        let mut tree_conflicted = 0;
1222        let err = unsafe {
1223            subversion_sys::svn_wc_conflicted_p3(
1224                &mut text_conflicted,
1225                &mut prop_conflicted,
1226                &mut tree_conflicted,
1227                self.ptr,
1228                path.as_ptr(),
1229                scratch_pool.as_mut_ptr(),
1230            )
1231        };
1232        Error::from_raw(err)?;
1233        Ok((
1234            text_conflicted != 0,
1235            prop_conflicted != 0,
1236            tree_conflicted != 0,
1237        ))
1238    }
1239
1240    /// Ensures an administrative area exists for the given path.
1241    pub fn ensure_adm(
1242        &mut self,
1243        local_abspath: &str,
1244        url: &str,
1245        repos_root_url: &str,
1246        repos_uuid: &str,
1247        revision: crate::Revnum,
1248        depth: crate::Depth,
1249    ) -> Result<(), crate::Error<'_>> {
1250        let scratch_pool = apr::pool::Pool::new();
1251        let local_abspath = crate::dirent::to_absolute_cstring(local_abspath)?;
1252        let url = crate::uri::canonicalize_uri(url)?;
1253        let url = std::ffi::CString::new(url.as_str()).unwrap();
1254        let repos_root_url = crate::uri::canonicalize_uri(repos_root_url)?;
1255        let repos_root_url = std::ffi::CString::new(repos_root_url.as_str()).unwrap();
1256        let repos_uuid = std::ffi::CString::new(repos_uuid).unwrap();
1257        let err = unsafe {
1258            subversion_sys::svn_wc_ensure_adm4(
1259                self.ptr,
1260                local_abspath.as_ptr(),
1261                url.as_ptr(),
1262                repos_root_url.as_ptr(),
1263                repos_uuid.as_ptr(),
1264                revision.0,
1265                depth.into(),
1266                scratch_pool.as_mut_ptr(),
1267            )
1268        };
1269        Error::from_raw(err)?;
1270        Ok(())
1271    }
1272
1273    /// Checks if a path is locked in the working copy.
1274    /// Returns (locked_here, locked) where locked_here means locked in this working copy.
1275    pub fn locked(&mut self, path: &str) -> Result<(bool, bool), crate::Error<'_>> {
1276        let path = crate::dirent::to_absolute_cstring(path)?;
1277        let mut locked = 0;
1278        let mut locked_here = 0;
1279        let scratch_pool = apr::pool::Pool::new();
1280        let err = unsafe {
1281            subversion_sys::svn_wc_locked2(
1282                &mut locked_here,
1283                &mut locked,
1284                self.ptr,
1285                path.as_ptr(),
1286                scratch_pool.as_mut_ptr(),
1287            )
1288        };
1289        Error::from_raw(err)?;
1290        Ok((locked != 0, locked_here != 0))
1291    }
1292
1293    /// Get the working copy database format version for this context
1294    pub fn db_version(&self) -> Result<i32, crate::Error<'_>> {
1295        // This would require exposing more internal SVN APIs
1296        // For now, just indicate we don't have this information
1297        Ok(0) // 0 indicates unknown/unavailable
1298    }
1299
1300    /// Upgrade a working copy to the latest format
1301    pub fn upgrade(&mut self, local_abspath: &str) -> Result<(), crate::Error<'_>> {
1302        let local_abspath_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
1303        let scratch_pool = apr::pool::Pool::new();
1304
1305        let err = unsafe {
1306            subversion_sys::svn_wc_upgrade(
1307                self.ptr,
1308                local_abspath_cstr.as_ptr(),
1309                None,                 // repos_info_func
1310                std::ptr::null_mut(), // repos_info_baton
1311                None,                 // cancel_func
1312                std::ptr::null_mut(), // cancel_baton
1313                None,                 // notify_func
1314                std::ptr::null_mut(), // notify_baton
1315                scratch_pool.as_mut_ptr(),
1316            )
1317        };
1318        Error::from_raw(err)?;
1319        Ok(())
1320    }
1321
1322    /// Relocate the working copy to a new repository URL
1323    pub fn relocate(
1324        &mut self,
1325        wcroot_abspath: &str,
1326        from: &str,
1327        to: &str,
1328    ) -> Result<(), crate::Error<'_>> {
1329        let wcroot_abspath_cstr = crate::dirent::to_absolute_cstring(wcroot_abspath)?;
1330        let from_cstr = std::ffi::CString::new(from)?;
1331        let to_cstr = std::ffi::CString::new(to)?;
1332        let scratch_pool = apr::pool::Pool::new();
1333
1334        // Default validator that accepts all relocations
1335        unsafe extern "C" fn default_validator(
1336            _baton: *mut std::ffi::c_void,
1337            _uuid: *const std::ffi::c_char,
1338            _url: *const std::ffi::c_char,
1339            _root_url: *const std::ffi::c_char,
1340            _pool: *mut apr_sys::apr_pool_t,
1341        ) -> *mut subversion_sys::svn_error_t {
1342            std::ptr::null_mut() // No error = validation successful
1343        }
1344
1345        let err = unsafe {
1346            subversion_sys::svn_wc_relocate4(
1347                self.ptr,
1348                wcroot_abspath_cstr.as_ptr(),
1349                from_cstr.as_ptr(),
1350                to_cstr.as_ptr(),
1351                Some(default_validator),
1352                std::ptr::null_mut(), // validator_baton
1353                scratch_pool.as_mut_ptr(),
1354            )
1355        };
1356        Error::from_raw(err)?;
1357        Ok(())
1358    }
1359
1360    /// Add a file or directory to the working copy
1361    pub fn add(
1362        &mut self,
1363        local_abspath: &str,
1364        depth: crate::Depth,
1365        copyfrom_url: Option<&str>,
1366        copyfrom_rev: Option<crate::Revnum>,
1367    ) -> Result<(), crate::Error<'_>> {
1368        let local_abspath_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
1369        let copyfrom_url_cstr = copyfrom_url.map(std::ffi::CString::new).transpose()?;
1370        let scratch_pool = apr::pool::Pool::new();
1371
1372        let err = unsafe {
1373            subversion_sys::svn_wc_add4(
1374                self.ptr,
1375                local_abspath_cstr.as_ptr(),
1376                depth.into(),
1377                copyfrom_url_cstr
1378                    .as_ref()
1379                    .map_or(std::ptr::null(), |c| c.as_ptr()),
1380                copyfrom_rev.map_or(-1, |r| r.into()),
1381                None,                 // cancel_func
1382                std::ptr::null_mut(), // cancel_baton
1383                None,                 // notify_func
1384                std::ptr::null_mut(), // notify_baton
1385                scratch_pool.as_mut_ptr(),
1386            )
1387        };
1388        Error::from_raw(err)?;
1389        Ok(())
1390    }
1391}
1392
1393/// Sets the name of the administrative directory (typically ".svn").
1394pub fn set_adm_dir(name: &str) -> Result<(), crate::Error<'_>> {
1395    let scratch_pool = apr::pool::Pool::new();
1396    let name = std::ffi::CString::new(name).unwrap();
1397    let err =
1398        unsafe { subversion_sys::svn_wc_set_adm_dir(name.as_ptr(), scratch_pool.as_mut_ptr()) };
1399    Error::from_raw(err)?;
1400    Ok(())
1401}
1402
1403/// Returns the name of the administrative directory.
1404pub fn get_adm_dir() -> String {
1405    let pool = apr::pool::Pool::new();
1406    let name = unsafe { subversion_sys::svn_wc_get_adm_dir(pool.as_mut_ptr()) };
1407    unsafe { std::ffi::CStr::from_ptr(name) }
1408        .to_string_lossy()
1409        .into_owned()
1410}
1411
1412/// Check if text is modified in a working copy file
1413pub fn text_modified(
1414    path: &std::path::Path,
1415    force_comparison: bool,
1416) -> Result<bool, crate::Error<'_>> {
1417    let path_cstr = crate::dirent::to_absolute_cstring(path)?;
1418    let mut modified = 0;
1419
1420    with_tmp_pool(|pool| -> Result<(), crate::Error> {
1421        let mut ctx = std::ptr::null_mut();
1422        with_tmp_pool(|scratch_pool| {
1423            let err = unsafe {
1424                subversion_sys::svn_wc_context_create(
1425                    &mut ctx,
1426                    std::ptr::null_mut(),
1427                    pool.as_mut_ptr(),
1428                    scratch_pool.as_mut_ptr(),
1429                )
1430            };
1431            svn_result(err)
1432        })?;
1433
1434        let err = unsafe {
1435            subversion_sys::svn_wc_text_modified_p2(
1436                &mut modified,
1437                ctx,
1438                path_cstr.as_ptr(),
1439                if force_comparison { 1 } else { 0 },
1440                pool.as_mut_ptr(),
1441            )
1442        };
1443        Error::from_raw(err)?;
1444        Ok(())
1445    })?;
1446
1447    Ok(modified != 0)
1448}
1449
1450/// Check if properties are modified in a working copy file
1451pub fn props_modified(path: &std::path::Path) -> Result<bool, crate::Error<'_>> {
1452    let path_cstr = crate::dirent::to_absolute_cstring(path)?;
1453    let mut modified = 0;
1454
1455    with_tmp_pool(|pool| -> Result<(), crate::Error> {
1456        let mut ctx = std::ptr::null_mut();
1457        with_tmp_pool(|scratch_pool| {
1458            let err = unsafe {
1459                subversion_sys::svn_wc_context_create(
1460                    &mut ctx,
1461                    std::ptr::null_mut(),
1462                    pool.as_mut_ptr(),
1463                    scratch_pool.as_mut_ptr(),
1464                )
1465            };
1466            svn_result(err)
1467        })?;
1468
1469        let err = unsafe {
1470            subversion_sys::svn_wc_props_modified_p2(
1471                &mut modified,
1472                ctx,
1473                path_cstr.as_ptr(),
1474                pool.as_mut_ptr(),
1475            )
1476        };
1477        Error::from_raw(err)?;
1478        Ok(())
1479    })?;
1480
1481    Ok(modified != 0)
1482}
1483
1484/// Check if directory name is an administrative directory
1485pub fn is_adm_dir(name: &str) -> bool {
1486    let name_cstr = std::ffi::CString::new(name).unwrap();
1487    let pool = apr::Pool::new();
1488    let result =
1489        unsafe { subversion_sys::svn_wc_is_adm_dir(name_cstr.as_ptr(), pool.as_mut_ptr()) };
1490    result != 0
1491}
1492
1493/// Crawl local changes in the working copy and report them to the repository
1494#[cfg(feature = "ra")]
1495pub fn crawl_revisions5(
1496    wc_ctx: &mut Context,
1497    local_abspath: &str,
1498    reporter: &mut crate::ra::WrapReporter,
1499    restore_files: bool,
1500    depth: crate::Depth,
1501    honor_depth_exclude: bool,
1502    depth_compatibility_trick: bool,
1503    use_commit_times: bool,
1504    notify_func: Option<&dyn Fn(&Notify)>,
1505) -> Result<(), crate::Error<'static>> {
1506    let local_abspath_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
1507
1508    let notify_baton = notify_func
1509        .map(|f| box_notify_baton_borrowed(f))
1510        .unwrap_or(std::ptr::null_mut());
1511
1512    let result = with_tmp_pool(|scratch_pool| {
1513        let err = unsafe {
1514            subversion_sys::svn_wc_crawl_revisions5(
1515                wc_ctx.as_mut_ptr(),
1516                local_abspath_cstr.as_ptr(),
1517                reporter.as_ptr(),
1518                reporter.as_baton(),
1519                if restore_files { 1 } else { 0 },
1520                depth.into(),
1521                if honor_depth_exclude { 1 } else { 0 },
1522                if depth_compatibility_trick { 1 } else { 0 },
1523                if use_commit_times { 1 } else { 0 },
1524                None,                 // cancel_func
1525                std::ptr::null_mut(), // cancel_baton
1526                if notify_func.is_some() {
1527                    Some(wrap_notify_func)
1528                } else {
1529                    None
1530                },
1531                notify_baton,
1532                scratch_pool.as_mut_ptr(),
1533            )
1534        };
1535
1536        svn_result(err)
1537    });
1538
1539    if !notify_baton.is_null() {
1540        unsafe { drop_notify_baton_borrowed(notify_baton) };
1541    }
1542
1543    result
1544}
1545
1546/// Get an editor for updating the working copy
1547///
1548/// The returned editor borrows from the context and must not outlive it.
1549pub fn get_update_editor4<'a>(
1550    wc_ctx: &'a mut Context,
1551    anchor_abspath: &str,
1552    target_basename: &str,
1553    options: UpdateEditorOptions,
1554) -> Result<(UpdateEditor<'a>, crate::Revnum), crate::Error<'static>> {
1555    let anchor_abspath_cstr = crate::dirent::to_absolute_cstring(anchor_abspath)?;
1556    let target_basename_cstr = std::ffi::CString::new(target_basename)?;
1557    let diff3_cmd_cstr = options.diff3_cmd.map(std::ffi::CString::new).transpose()?;
1558
1559    let result_pool = apr::Pool::new();
1560
1561    // Create preserved extensions array
1562    let preserved_exts_cstrs: Vec<std::ffi::CString> = options
1563        .preserved_exts
1564        .iter()
1565        .map(|&s| std::ffi::CString::new(s))
1566        .collect::<Result<Vec<_>, _>>()?;
1567    let preserved_exts_apr = if preserved_exts_cstrs.is_empty() {
1568        std::ptr::null()
1569    } else {
1570        let mut arr = apr::tables::TypedArray::<*const i8>::new(
1571            &result_pool,
1572            preserved_exts_cstrs.len() as i32,
1573        );
1574        for cstr in &preserved_exts_cstrs {
1575            arr.push(cstr.as_ptr());
1576        }
1577        unsafe { arr.as_ptr() }
1578    };
1579    let mut target_revision: subversion_sys::svn_revnum_t = 0;
1580    let mut editor_ptr: *const subversion_sys::svn_delta_editor_t = std::ptr::null();
1581    let mut edit_baton: *mut std::ffi::c_void = std::ptr::null_mut();
1582
1583    // Create batons for callbacks
1584    let has_fetch_dirents = options.fetch_dirents_func.is_some();
1585    let fetch_dirents_baton = options
1586        .fetch_dirents_func
1587        .map(|f| box_fetch_dirents_baton(f))
1588        .unwrap_or(std::ptr::null_mut());
1589    let has_conflict = options.conflict_func.is_some();
1590    let conflict_baton = options
1591        .conflict_func
1592        .map(|f| box_conflict_baton(f))
1593        .unwrap_or(std::ptr::null_mut());
1594    let has_external = options.external_func.is_some();
1595    let external_baton = options
1596        .external_func
1597        .map(|f| box_external_baton(f))
1598        .unwrap_or(std::ptr::null_mut());
1599    let has_cancel = options.cancel_func.is_some();
1600    let cancel_baton = options
1601        .cancel_func
1602        .map(box_cancel_baton)
1603        .unwrap_or(std::ptr::null_mut());
1604    let has_notify = options.notify_func.is_some();
1605    let notify_baton = options
1606        .notify_func
1607        .map(|f| box_notify_baton(f))
1608        .unwrap_or(std::ptr::null_mut());
1609
1610    let err = with_tmp_pool(|scratch_pool| unsafe {
1611        svn_result(subversion_sys::svn_wc_get_update_editor4(
1612            &mut editor_ptr,
1613            &mut edit_baton,
1614            &mut target_revision,
1615            wc_ctx.as_mut_ptr(),
1616            anchor_abspath_cstr.as_ptr(),
1617            target_basename_cstr.as_ptr(),
1618            if options.use_commit_times { 1 } else { 0 },
1619            options.depth.into(),
1620            if options.depth_is_sticky { 1 } else { 0 },
1621            if options.allow_unver_obstructions {
1622                1
1623            } else {
1624                0
1625            },
1626            if options.adds_as_modification { 1 } else { 0 },
1627            if options.server_performs_filtering {
1628                1
1629            } else {
1630                0
1631            },
1632            if options.clean_checkout { 1 } else { 0 },
1633            diff3_cmd_cstr
1634                .as_ref()
1635                .map_or(std::ptr::null(), |c| c.as_ptr()),
1636            preserved_exts_apr,
1637            if has_fetch_dirents {
1638                Some(wrap_fetch_dirents_func)
1639            } else {
1640                None
1641            },
1642            fetch_dirents_baton,
1643            if has_conflict {
1644                Some(wrap_conflict_func)
1645            } else {
1646                None
1647            },
1648            conflict_baton,
1649            if has_external {
1650                Some(wrap_external_func)
1651            } else {
1652                None
1653            },
1654            external_baton,
1655            if has_cancel {
1656                Some(crate::wrap_cancel_func)
1657            } else {
1658                None
1659            },
1660            cancel_baton,
1661            if has_notify {
1662                Some(wrap_notify_func)
1663            } else {
1664                None
1665            },
1666            notify_baton,
1667            result_pool.as_mut_ptr(),
1668            scratch_pool.as_mut_ptr(),
1669        ))
1670    });
1671
1672    err?;
1673
1674    // Create the update editor wrapper
1675    // Store callback batons with their droppers so they're properly cleaned up
1676    let mut batons = Vec::new();
1677    if !fetch_dirents_baton.is_null() {
1678        batons.push((fetch_dirents_baton, drop_fetch_dirents_baton as DropperFn));
1679    }
1680    if !conflict_baton.is_null() {
1681        batons.push((conflict_baton, drop_conflict_baton as DropperFn));
1682    }
1683    if !external_baton.is_null() {
1684        batons.push((external_baton, drop_external_baton as DropperFn));
1685    }
1686    if !cancel_baton.is_null() {
1687        batons.push((cancel_baton, drop_cancel_baton as DropperFn));
1688    }
1689    if !notify_baton.is_null() {
1690        batons.push((notify_baton, drop_notify_baton as DropperFn));
1691    }
1692
1693    let editor = UpdateEditor {
1694        editor: editor_ptr,
1695        edit_baton,
1696        _pool: result_pool,
1697        target_revision: crate::Revnum::from_raw(target_revision).unwrap_or_default(),
1698        callback_batons: batons,
1699        _marker: std::marker::PhantomData,
1700    };
1701
1702    Ok((
1703        editor,
1704        crate::Revnum::from_raw(target_revision).unwrap_or_default(),
1705    ))
1706}
1707
1708// Type-erased dropper function for callback batons
1709type DropperFn = unsafe fn(*mut std::ffi::c_void);
1710
1711/// Options for get_update_editor4 function.
1712#[derive(Default)]
1713pub struct UpdateEditorOptions<'a> {
1714    /// If true, use commit times for file timestamps.
1715    pub use_commit_times: bool,
1716    /// Depth of the update operation.
1717    pub depth: crate::Depth,
1718    /// If true, depth changes are sticky.
1719    pub depth_is_sticky: bool,
1720    /// If true, allow unversioned obstructions.
1721    pub allow_unver_obstructions: bool,
1722    /// If true, treat adds as modifications.
1723    pub adds_as_modification: bool,
1724    /// If true, server performs filtering.
1725    pub server_performs_filtering: bool,
1726    /// If true, this is a clean checkout.
1727    pub clean_checkout: bool,
1728    /// Path to diff3 command for merging.
1729    pub diff3_cmd: Option<&'a str>,
1730    /// File extensions to preserve during merge.
1731    pub preserved_exts: Vec<&'a str>,
1732    /// Callback to fetch directory entries.
1733    pub fetch_dirents_func: Option<
1734        Box<
1735            dyn Fn(
1736                &str,
1737                &str,
1738            )
1739                -> Result<std::collections::HashMap<String, crate::DirEntry>, Error<'static>>,
1740        >,
1741    >,
1742    /// Callback for conflict resolution.
1743    pub conflict_func: Option<
1744        Box<
1745            dyn Fn(
1746                &crate::conflict::ConflictDescription,
1747            ) -> Result<crate::conflict::ConflictResult, Error<'static>>,
1748        >,
1749    >,
1750    /// Callback for external definitions.
1751    pub external_func: Option<
1752        Box<dyn Fn(&str, Option<&str>, Option<&str>, crate::Depth) -> Result<(), Error<'static>>>,
1753    >,
1754    /// Callback for cancellation.
1755    pub cancel_func: Option<Box<dyn Fn() -> Result<(), Error<'static>>>>,
1756    /// Callback for notifications.
1757    pub notify_func: Option<Box<dyn Fn(&Notify)>>,
1758}
1759
1760impl<'a> UpdateEditorOptions<'a> {
1761    /// Creates new UpdateEditorOptions with default values.
1762    pub fn new() -> Self {
1763        Self::default()
1764    }
1765
1766    /// Sets whether to use commit times.
1767    pub fn with_use_commit_times(mut self, use_commit_times: bool) -> Self {
1768        self.use_commit_times = use_commit_times;
1769        self
1770    }
1771
1772    /// Sets the depth for the operation.
1773    pub fn with_depth(mut self, depth: crate::Depth) -> Self {
1774        self.depth = depth;
1775        self
1776    }
1777
1778    /// Sets whether depth is sticky.
1779    pub fn with_depth_is_sticky(mut self, sticky: bool) -> Self {
1780        self.depth_is_sticky = sticky;
1781        self
1782    }
1783
1784    /// Sets whether to allow unversioned obstructions.
1785    pub fn with_allow_unver_obstructions(mut self, allow: bool) -> Self {
1786        self.allow_unver_obstructions = allow;
1787        self
1788    }
1789
1790    /// Sets whether adds are treated as modifications.
1791    pub fn with_adds_as_modification(mut self, adds_as_mod: bool) -> Self {
1792        self.adds_as_modification = adds_as_mod;
1793        self
1794    }
1795
1796    /// Sets the diff3 command path.
1797    pub fn with_diff3_cmd(mut self, cmd: &'a str) -> Self {
1798        self.diff3_cmd = Some(cmd);
1799        self
1800    }
1801
1802    /// Sets the preserved extensions.
1803    pub fn with_preserved_exts(mut self, exts: Vec<&'a str>) -> Self {
1804        self.preserved_exts = exts;
1805        self
1806    }
1807}
1808
1809/// Options for get_switch_editor function.
1810#[derive(Default)]
1811pub struct SwitchEditorOptions<'a> {
1812    /// If true, use commit times for file timestamps.
1813    pub use_commit_times: bool,
1814    /// Depth of the switch operation.
1815    pub depth: crate::Depth,
1816    /// If true, depth changes are sticky.
1817    pub depth_is_sticky: bool,
1818    /// If true, allow unversioned obstructions.
1819    pub allow_unver_obstructions: bool,
1820    /// If true, server performs filtering.
1821    pub server_performs_filtering: bool,
1822    /// Path to diff3 command for merging.
1823    pub diff3_cmd: Option<&'a str>,
1824    /// File extensions to preserve during merge.
1825    pub preserved_exts: Vec<&'a str>,
1826    /// Callback to fetch directory entries.
1827    pub fetch_dirents_func: Option<
1828        Box<
1829            dyn Fn(
1830                &str,
1831                &str,
1832            )
1833                -> Result<std::collections::HashMap<String, crate::DirEntry>, Error<'static>>,
1834        >,
1835    >,
1836    /// Callback for conflict resolution.
1837    pub conflict_func: Option<
1838        Box<
1839            dyn Fn(
1840                &crate::conflict::ConflictDescription,
1841            ) -> Result<crate::conflict::ConflictResult, Error<'static>>,
1842        >,
1843    >,
1844    /// Callback for external definitions.
1845    pub external_func: Option<
1846        Box<dyn Fn(&str, Option<&str>, Option<&str>, crate::Depth) -> Result<(), Error<'static>>>,
1847    >,
1848    /// Callback for cancellation.
1849    pub cancel_func: Option<Box<dyn Fn() -> Result<(), Error<'static>>>>,
1850    /// Callback for notifications.
1851    pub notify_func: Option<Box<dyn Fn(&Notify)>>,
1852}
1853
1854impl<'a> SwitchEditorOptions<'a> {
1855    /// Creates new SwitchEditorOptions with default values.
1856    pub fn new() -> Self {
1857        Self::default()
1858    }
1859
1860    /// Sets whether to use commit times.
1861    pub fn with_use_commit_times(mut self, use_commit_times: bool) -> Self {
1862        self.use_commit_times = use_commit_times;
1863        self
1864    }
1865
1866    /// Sets the depth for the operation.
1867    pub fn with_depth(mut self, depth: crate::Depth) -> Self {
1868        self.depth = depth;
1869        self
1870    }
1871
1872    /// Sets whether depth is sticky.
1873    pub fn with_depth_is_sticky(mut self, sticky: bool) -> Self {
1874        self.depth_is_sticky = sticky;
1875        self
1876    }
1877
1878    /// Sets whether to allow unversioned obstructions.
1879    pub fn with_allow_unver_obstructions(mut self, allow: bool) -> Self {
1880        self.allow_unver_obstructions = allow;
1881        self
1882    }
1883
1884    /// Sets the diff3 command path.
1885    pub fn with_diff3_cmd(mut self, cmd: &'a str) -> Self {
1886        self.diff3_cmd = Some(cmd);
1887        self
1888    }
1889
1890    /// Sets the preserved extensions.
1891    pub fn with_preserved_exts(mut self, exts: Vec<&'a str>) -> Self {
1892        self.preserved_exts = exts;
1893        self
1894    }
1895}
1896
1897/// Update editor for working copy operations
1898///
1899/// The lifetime parameter ensures the editor does not outlive the Context it was created from.
1900pub struct UpdateEditor<'a> {
1901    editor: *const subversion_sys::svn_delta_editor_t,
1902    edit_baton: *mut std::ffi::c_void,
1903    _pool: apr::Pool<'static>,
1904    target_revision: crate::Revnum,
1905    // Callback batons with their dropper functions
1906    callback_batons: Vec<(*mut std::ffi::c_void, DropperFn)>,
1907    _marker: std::marker::PhantomData<&'a Context>,
1908}
1909
1910impl Drop for UpdateEditor<'_> {
1911    fn drop(&mut self) {
1912        // Clean up callback batons using their type-erased droppers
1913        for (baton, dropper) in &self.callback_batons {
1914            if !baton.is_null() {
1915                unsafe {
1916                    dropper(*baton);
1917                }
1918            }
1919        }
1920        self.callback_batons.clear();
1921    }
1922}
1923
1924impl UpdateEditor<'_> {
1925    /// Get the target revision for this update
1926    pub fn target_revision(&self) -> crate::Revnum {
1927        self.target_revision
1928    }
1929
1930    /// Convert into a WrapEditor for use with PyEditor.
1931    ///
1932    /// This consumes the UpdateEditor and produces a WrapEditor that
1933    /// forwards calls to the same underlying C editor/baton pair.
1934    pub fn into_wrap_editor(mut self) -> crate::delta::WrapEditor<'static> {
1935        let editor = self.editor;
1936        let baton = self.edit_baton;
1937        let pool = std::mem::replace(&mut self._pool, apr::Pool::new());
1938        let batons = std::mem::take(&mut self.callback_batons);
1939
1940        // Prevent the drop impl from cleaning up the batons
1941        // since we're transferring ownership to WrapEditor
1942        std::mem::forget(self);
1943
1944        crate::delta::WrapEditor {
1945            editor,
1946            baton,
1947            _pool: apr::pool::PoolHandle::owned(pool),
1948            callback_batons: batons,
1949        }
1950    }
1951}
1952
1953impl crate::delta::Editor for UpdateEditor<'_> {
1954    type RootEditor = crate::delta::WrapDirectoryEditor<'static>;
1955
1956    fn set_target_revision(
1957        &mut self,
1958        revision: Option<crate::Revnum>,
1959    ) -> Result<(), crate::Error<'_>> {
1960        let scratch_pool = apr::Pool::new();
1961        let err = unsafe {
1962            ((*self.editor).set_target_revision.unwrap())(
1963                self.edit_baton,
1964                revision.map_or(-1, |r| r.into()),
1965                scratch_pool.as_mut_ptr(),
1966            )
1967        };
1968        crate::Error::from_raw(err)?;
1969        Ok(())
1970    }
1971
1972    fn open_root(
1973        &mut self,
1974        base_revision: Option<crate::Revnum>,
1975    ) -> Result<crate::delta::WrapDirectoryEditor<'static>, crate::Error<'_>> {
1976        let mut baton = std::ptr::null_mut();
1977        let pool = apr::Pool::new();
1978        let err = unsafe {
1979            ((*self.editor).open_root.unwrap())(
1980                self.edit_baton,
1981                base_revision.map_or(-1, |r| r.into()),
1982                pool.as_mut_ptr(),
1983                &mut baton,
1984            )
1985        };
1986        crate::Error::from_raw(err)?;
1987        Ok(crate::delta::WrapDirectoryEditor {
1988            editor: self.editor,
1989            baton,
1990            _pool: apr::PoolHandle::owned(pool),
1991        })
1992    }
1993
1994    fn close(&mut self) -> Result<(), crate::Error<'_>> {
1995        let scratch_pool = apr::Pool::new();
1996        let err = unsafe {
1997            ((*self.editor).close_edit.unwrap())(self.edit_baton, scratch_pool.as_mut_ptr())
1998        };
1999        crate::Error::from_raw(err)?;
2000        Ok(())
2001    }
2002
2003    fn abort(&mut self) -> Result<(), crate::Error<'_>> {
2004        let scratch_pool = apr::Pool::new();
2005        let err = unsafe {
2006            ((*self.editor).abort_edit.unwrap())(self.edit_baton, scratch_pool.as_mut_ptr())
2007        };
2008        crate::Error::from_raw(err)?;
2009        Ok(())
2010    }
2011}
2012
2013/// Directory entries type for working copy operations
2014pub type DirEntries = std::collections::HashMap<String, crate::DirEntry>;
2015
2016/// Check working copy format at path
2017pub fn check_wc(path: &std::path::Path) -> Result<Option<i32>, crate::Error<'_>> {
2018    let path_cstr = crate::dirent::to_absolute_cstring(path)?;
2019    let mut wc_format = 0;
2020
2021    with_tmp_pool(|pool| -> Result<(), crate::Error> {
2022        let mut ctx = std::ptr::null_mut();
2023        with_tmp_pool(|scratch_pool| {
2024            let err = unsafe {
2025                subversion_sys::svn_wc_context_create(
2026                    &mut ctx,
2027                    std::ptr::null_mut(),
2028                    pool.as_mut_ptr(),
2029                    scratch_pool.as_mut_ptr(),
2030                )
2031            };
2032            svn_result(err)
2033        })?;
2034
2035        let err = unsafe {
2036            subversion_sys::svn_wc_check_wc2(
2037                &mut wc_format,
2038                ctx,
2039                path_cstr.as_ptr(),
2040                pool.as_mut_ptr(),
2041            )
2042        };
2043        Error::from_raw(err)?;
2044        Ok(())
2045    })?;
2046
2047    // Return None if not a working copy (format would be 0)
2048    if wc_format == 0 {
2049        Ok(None)
2050    } else {
2051        Ok(Some(wc_format))
2052    }
2053}
2054
2055/// Ensure administrative directory exists
2056pub fn ensure_adm(
2057    path: &std::path::Path,
2058    uuid: &str,
2059    url: &str,
2060    repos_root: &str,
2061    revision: i64,
2062) -> Result<(), crate::Error<'static>> {
2063    let path_cstr = crate::dirent::to_absolute_cstring(path)?;
2064    let uuid_cstr = std::ffi::CString::new(uuid).unwrap();
2065    let url = crate::uri::canonicalize_uri(url)?;
2066    let url_cstr = std::ffi::CString::new(url.as_str()).unwrap();
2067    let repos_root = crate::uri::canonicalize_uri(repos_root)?;
2068    let repos_root_cstr = std::ffi::CString::new(repos_root.as_str()).unwrap();
2069
2070    with_tmp_pool(|pool| -> Result<(), crate::Error> {
2071        let mut ctx = std::ptr::null_mut();
2072        with_tmp_pool(|scratch_pool| {
2073            let err = unsafe {
2074                subversion_sys::svn_wc_context_create(
2075                    &mut ctx,
2076                    std::ptr::null_mut(),
2077                    pool.as_mut_ptr(),
2078                    scratch_pool.as_mut_ptr(),
2079                )
2080            };
2081            svn_result(err)
2082        })?;
2083
2084        let err = unsafe {
2085            subversion_sys::svn_wc_ensure_adm4(
2086                ctx,
2087                path_cstr.as_ptr(),
2088                url_cstr.as_ptr(),
2089                repos_root_cstr.as_ptr(),
2090                uuid_cstr.as_ptr(),
2091                revision as subversion_sys::svn_revnum_t,
2092                subversion_sys::svn_depth_t_svn_depth_infinity,
2093                pool.as_mut_ptr(),
2094            )
2095        };
2096        Error::from_raw(err)?;
2097        Ok(())
2098    })
2099}
2100
2101/// Check if a property name is a "normal" property (not special)
2102pub fn is_normal_prop(name: &str) -> bool {
2103    let name_cstr = std::ffi::CString::new(name).unwrap();
2104    unsafe { subversion_sys::svn_wc_is_normal_prop(name_cstr.as_ptr()) != 0 }
2105}
2106
2107/// Check if a property name is an "entry" property
2108pub fn is_entry_prop(name: &str) -> bool {
2109    let name_cstr = std::ffi::CString::new(name).unwrap();
2110    unsafe { subversion_sys::svn_wc_is_entry_prop(name_cstr.as_ptr()) != 0 }
2111}
2112
2113/// Check if a property name is a "wc" property
2114pub fn is_wc_prop(name: &str) -> bool {
2115    let name_cstr = std::ffi::CString::new(name).unwrap();
2116    unsafe { subversion_sys::svn_wc_is_wc_prop(name_cstr.as_ptr()) != 0 }
2117}
2118
2119/// Match a path against an ignore list
2120pub fn match_ignore_list(path: &str, patterns: &[&str]) -> Result<bool, crate::Error<'static>> {
2121    let path_cstr = std::ffi::CString::new(path)?;
2122
2123    with_tmp_pool(|pool| {
2124        // We need to keep the CStrings alive for the duration of the call
2125        let pattern_cstrs: Vec<std::ffi::CString> = patterns
2126            .iter()
2127            .map(|p| std::ffi::CString::new(*p))
2128            .collect::<Result<Vec<_>, _>>()?;
2129
2130        // Create APR array of patterns
2131        let mut patterns_array =
2132            apr::tables::TypedArray::<*const i8>::new(pool, patterns.len() as i32);
2133        for pattern_cstr in &pattern_cstrs {
2134            patterns_array.push(pattern_cstr.as_ptr());
2135        }
2136
2137        let matched = unsafe {
2138            subversion_sys::svn_wc_match_ignore_list(
2139                path_cstr.as_ptr(),
2140                patterns_array.as_ptr(),
2141                pool.as_mut_ptr(),
2142            )
2143        };
2144
2145        // svn_wc_match_ignore_list returns svn_boolean_t (0 = false, non-zero = true)
2146        Ok(matched != 0)
2147    })
2148}
2149
2150/// Get the actual target for a path (anchor/target split)
2151pub fn get_actual_target(path: &std::path::Path) -> Result<(String, String), crate::Error<'_>> {
2152    let path_cstr = crate::dirent::to_absolute_cstring(path)?;
2153    let mut anchor: *const i8 = std::ptr::null();
2154    let mut target: *const i8 = std::ptr::null();
2155
2156    with_tmp_pool(|pool| -> Result<(), crate::Error> {
2157        let mut ctx = std::ptr::null_mut();
2158        with_tmp_pool(|scratch_pool| {
2159            let err = unsafe {
2160                subversion_sys::svn_wc_context_create(
2161                    &mut ctx,
2162                    std::ptr::null_mut(),
2163                    pool.as_mut_ptr(),
2164                    scratch_pool.as_mut_ptr(),
2165                )
2166            };
2167            svn_result(err)
2168        })?;
2169
2170        let err = unsafe {
2171            subversion_sys::svn_wc_get_actual_target2(
2172                &mut anchor,
2173                &mut target,
2174                ctx,
2175                path_cstr.as_ptr(),
2176                pool.as_mut_ptr(),
2177                pool.as_mut_ptr(),
2178            )
2179        };
2180        Error::from_raw(err)?;
2181        Ok(())
2182    })?;
2183
2184    let anchor_str = if anchor.is_null() {
2185        String::new()
2186    } else {
2187        unsafe { std::ffi::CStr::from_ptr(anchor) }
2188            .to_string_lossy()
2189            .into_owned()
2190    };
2191
2192    let target_str = if target.is_null() {
2193        String::new()
2194    } else {
2195        unsafe { std::ffi::CStr::from_ptr(target) }
2196            .to_string_lossy()
2197            .into_owned()
2198    };
2199
2200    Ok((anchor_str, target_str))
2201}
2202
2203/// Get pristine contents of a file
2204pub fn get_pristine_contents(
2205    path: &std::path::Path,
2206) -> Result<Option<crate::io::Stream>, crate::Error<'_>> {
2207    let path_cstr = crate::dirent::to_absolute_cstring(path)?;
2208    let mut contents: *mut subversion_sys::svn_stream_t = std::ptr::null_mut();
2209
2210    // Create a pool that will live as long as the Stream
2211    let result_pool = apr::Pool::new();
2212
2213    with_tmp_pool(|scratch_pool| -> Result<(), crate::Error> {
2214        let mut ctx = std::ptr::null_mut();
2215        with_tmp_pool(|ctx_scratch_pool| {
2216            let err = unsafe {
2217                subversion_sys::svn_wc_context_create(
2218                    &mut ctx,
2219                    std::ptr::null_mut(),
2220                    scratch_pool.as_mut_ptr(), // ctx lives in the outer scratch pool
2221                    ctx_scratch_pool.as_mut_ptr(),
2222                )
2223            };
2224            svn_result(err)
2225        })?;
2226
2227        let err = unsafe {
2228            subversion_sys::svn_wc_get_pristine_contents2(
2229                &mut contents,
2230                ctx,
2231                path_cstr.as_ptr(),
2232                result_pool.as_mut_ptr(),  // result pool for the stream
2233                scratch_pool.as_mut_ptr(), // scratch pool for temporary allocations
2234            )
2235        };
2236        Error::from_raw(err)?;
2237        Ok(())
2238    })?;
2239
2240    if contents.is_null() {
2241        Ok(None)
2242    } else {
2243        Ok(Some(unsafe {
2244            crate::io::Stream::from_ptr_and_pool(contents, result_pool)
2245        }))
2246    }
2247}
2248
2249/// Get pristine copy path (deprecated - for backwards compatibility)
2250pub fn get_pristine_copy_path(
2251    path: &std::path::Path,
2252) -> Result<std::path::PathBuf, crate::Error<'_>> {
2253    let path_cstr = crate::dirent::to_absolute_cstring(path)?;
2254    let mut pristine_path: *const i8 = std::ptr::null();
2255
2256    let pristine_path_str = with_tmp_pool(|pool| -> Result<String, crate::Error> {
2257        let err = unsafe {
2258            subversion_sys::svn_wc_get_pristine_copy_path(
2259                path_cstr.as_ptr(),
2260                &mut pristine_path,
2261                pool.as_mut_ptr(),
2262            )
2263        };
2264        Error::from_raw(err)?;
2265
2266        // Copy the string before the pool is destroyed
2267        let result = if pristine_path.is_null() {
2268            String::new()
2269        } else {
2270            unsafe { std::ffi::CStr::from_ptr(pristine_path) }
2271                .to_string_lossy()
2272                .into_owned()
2273        };
2274        Ok(result)
2275    })?;
2276
2277    Ok(std::path::PathBuf::from(pristine_path_str))
2278}
2279
2280impl Context {
2281    /// Get the actual target for a path using this working copy context
2282    pub fn get_actual_target(&mut self, path: &str) -> Result<(String, String), crate::Error<'_>> {
2283        let path_cstr = crate::dirent::to_absolute_cstring(path)?;
2284        let mut anchor: *const i8 = std::ptr::null();
2285        let mut target: *const i8 = std::ptr::null();
2286
2287        let pool = apr::Pool::new();
2288        let err = unsafe {
2289            subversion_sys::svn_wc_get_actual_target2(
2290                &mut anchor,
2291                &mut target,
2292                self.ptr,
2293                path_cstr.as_ptr(),
2294                pool.as_mut_ptr(),
2295                pool.as_mut_ptr(),
2296            )
2297        };
2298        Error::from_raw(err)?;
2299
2300        let anchor_str = if anchor.is_null() {
2301            String::new()
2302        } else {
2303            unsafe { std::ffi::CStr::from_ptr(anchor) }
2304                .to_string_lossy()
2305                .into_owned()
2306        };
2307
2308        let target_str = if target.is_null() {
2309            String::new()
2310        } else {
2311            unsafe { std::ffi::CStr::from_ptr(target) }
2312                .to_string_lossy()
2313                .into_owned()
2314        };
2315
2316        Ok((anchor_str, target_str))
2317    }
2318
2319    /// Get pristine contents of a file using this working copy context
2320    pub fn get_pristine_contents(
2321        &mut self,
2322        path: &str,
2323    ) -> Result<Option<crate::io::Stream>, crate::Error<'_>> {
2324        let path_cstr = crate::dirent::to_absolute_cstring(path)?;
2325        let mut contents: *mut subversion_sys::svn_stream_t = std::ptr::null_mut();
2326
2327        let pool = apr::Pool::new();
2328        let err = unsafe {
2329            subversion_sys::svn_wc_get_pristine_contents2(
2330                &mut contents,
2331                self.ptr,
2332                path_cstr.as_ptr(),
2333                pool.as_mut_ptr(),
2334                pool.as_mut_ptr(),
2335            )
2336        };
2337        Error::from_raw(err)?;
2338
2339        if contents.is_null() {
2340            Ok(None)
2341        } else {
2342            Ok(Some(unsafe {
2343                crate::io::Stream::from_ptr_and_pool(contents, pool)
2344            }))
2345        }
2346    }
2347
2348    /// Get pristine properties for a path
2349    pub fn get_pristine_props(
2350        &mut self,
2351        path: &str,
2352    ) -> Result<Option<std::collections::HashMap<String, Vec<u8>>>, crate::Error<'_>> {
2353        let path_cstr = crate::dirent::to_absolute_cstring(path)?;
2354        let mut props: *mut apr_sys::apr_hash_t = std::ptr::null_mut();
2355
2356        let pool = apr::Pool::new();
2357        let err = unsafe {
2358            subversion_sys::svn_wc_get_pristine_props(
2359                &mut props,
2360                self.ptr,
2361                path_cstr.as_ptr(),
2362                pool.as_mut_ptr(),
2363                pool.as_mut_ptr(),
2364            )
2365        };
2366        Error::from_raw(err)?;
2367
2368        if props.is_null() {
2369            return Ok(None);
2370        }
2371
2372        let prop_hash = unsafe { crate::props::PropHash::from_ptr(props) };
2373        Ok(Some(prop_hash.to_hashmap()))
2374    }
2375
2376    /// Walk the status of a working copy tree
2377    ///
2378    /// Walks the WC status for `local_abspath` and all its children, invoking
2379    /// the callback for each node.
2380    pub fn walk_status<F>(
2381        &mut self,
2382        local_abspath: &std::path::Path,
2383        depth: crate::Depth,
2384        get_all: bool,
2385        no_ignore: bool,
2386        ignore_text_mods: bool,
2387        ignore_patterns: Option<&[&str]>,
2388        status_func: F,
2389    ) -> Result<(), Error<'static>>
2390    where
2391        F: FnMut(&str, &Status<'_>) -> Result<(), Error<'static>>,
2392    {
2393        let pool = apr::Pool::new();
2394        let path_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
2395
2396        // Build ignore_patterns APR array if provided
2397        let pattern_cstrs: Vec<std::ffi::CString> = ignore_patterns
2398            .unwrap_or(&[])
2399            .iter()
2400            .map(|p| std::ffi::CString::new(*p).expect("pattern valid UTF-8"))
2401            .collect();
2402        let ignore_patterns_ptr = if let Some(patterns) = ignore_patterns {
2403            let mut arr = apr::tables::TypedArray::<*const std::os::raw::c_char>::new(
2404                &pool,
2405                patterns.len() as i32,
2406            );
2407            for cstr in &pattern_cstrs {
2408                arr.push(cstr.as_ptr());
2409            }
2410            unsafe { arr.as_ptr() }
2411        } else {
2412            std::ptr::null_mut()
2413        };
2414
2415        // Wrap the closure in a way that can be passed to C
2416        unsafe extern "C" fn status_callback(
2417            baton: *mut std::ffi::c_void,
2418            local_abspath: *const std::os::raw::c_char,
2419            status: *const subversion_sys::svn_wc_status3_t,
2420            scratch_pool: *mut apr_sys::apr_pool_t,
2421        ) -> *mut subversion_sys::svn_error_t {
2422            let callback = unsafe {
2423                &mut *(baton
2424                    as *mut Box<dyn FnMut(&str, &Status<'_>) -> Result<(), Error<'static>>>)
2425            };
2426
2427            let local_path =
2428                unsafe { subversion_sys::svn_dirent_local_style(local_abspath, scratch_pool) };
2429            let path = unsafe {
2430                std::ffi::CStr::from_ptr(local_path)
2431                    .to_string_lossy()
2432                    .into_owned()
2433            };
2434
2435            // Borrow SVN's scratch pool for the duration of this callback.
2436            // The pool is valid until the callback returns; Status<'_> cannot
2437            // escape because it is only passed as &Status<'_> to the closure.
2438            let status = Status {
2439                ptr: status,
2440                _pool: unsafe { apr::pool::PoolHandle::from_borrowed_raw(scratch_pool) },
2441            };
2442
2443            match callback(&path, &status) {
2444                Ok(()) => std::ptr::null_mut(),
2445                Err(e) => unsafe { e.into_raw() },
2446            }
2447        }
2448
2449        let boxed_callback: Box<Box<dyn FnMut(&str, &Status<'_>) -> Result<(), Error<'static>>>> =
2450            Box::new(Box::new(status_func));
2451        let baton = Box::into_raw(boxed_callback) as *mut std::ffi::c_void;
2452
2453        unsafe {
2454            let err = subversion_sys::svn_wc_walk_status(
2455                self.ptr,
2456                path_cstr.as_ptr(),
2457                depth.into(),
2458                get_all as i32,
2459                no_ignore as i32,
2460                ignore_text_mods as i32,
2461                ignore_patterns_ptr,
2462                Some(status_callback),
2463                baton,
2464                None,                 // cancel_func
2465                std::ptr::null_mut(), // cancel_baton
2466                pool.as_mut_ptr(),
2467            );
2468
2469            // Clean up the callback
2470            let _ = Box::from_raw(
2471                baton as *mut Box<dyn FnMut(&str, &Status<'_>) -> Result<(), Error<'static>>>,
2472            );
2473
2474            Error::from_raw(err)
2475        }
2476    }
2477
2478    /// Queue committed items for post-commit processing
2479    ///
2480    /// Queues items that have been committed for later processing by `process_committed_queue`.
2481    ///
2482    /// `is_committed` should be `true` for nodes that were actually committed (not just
2483    /// included in a recursive operation where they had no local changes).
2484    ///
2485    /// Wraps `svn_wc_queue_committed4`.
2486    pub fn queue_committed(
2487        &mut self,
2488        local_abspath: &std::path::Path,
2489        recurse: bool,
2490        is_committed: bool,
2491        committed_queue: &mut CommittedQueue,
2492        wcprop_changes: Option<&[PropChange]>,
2493        remove_lock: bool,
2494        remove_changelist: bool,
2495        sha1_checksum: Option<&crate::Checksum>,
2496    ) -> Result<(), Error<'static>> {
2497        let pool = apr::Pool::new();
2498        let path_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
2499
2500        // Build the wcprop_changes APR array if provided
2501        let wcprop_changes_ptr = if let Some(changes) = wcprop_changes {
2502            let prop_name_cstrs: Vec<std::ffi::CString> = changes
2503                .iter()
2504                .map(|c| std::ffi::CString::new(c.name.as_str()).expect("prop name valid UTF-8"))
2505                .collect();
2506            let mut arr = apr::tables::TypedArray::<subversion_sys::svn_prop_t>::new(
2507                &pool,
2508                changes.len() as i32,
2509            );
2510            for (change, name_cstr) in changes.iter().zip(prop_name_cstrs.iter()) {
2511                arr.push(subversion_sys::svn_prop_t {
2512                    name: name_cstr.as_ptr(),
2513                    value: if let Some(v) = &change.value {
2514                        crate::svn_string_helpers::svn_string_ncreate(v, &pool)
2515                    } else {
2516                        std::ptr::null()
2517                    },
2518                });
2519            }
2520            unsafe { arr.as_ptr() }
2521        } else {
2522            std::ptr::null()
2523        };
2524
2525        let sha1_ptr = sha1_checksum.map(|c| c.ptr).unwrap_or(std::ptr::null());
2526
2527        unsafe {
2528            let err = subversion_sys::svn_wc_queue_committed4(
2529                committed_queue.as_mut_ptr(),
2530                self.ptr,
2531                path_cstr.as_ptr(),
2532                recurse as i32,
2533                is_committed as i32,
2534                wcprop_changes_ptr,
2535                remove_lock as i32,
2536                remove_changelist as i32,
2537                sha1_ptr,
2538                pool.as_mut_ptr(),
2539            );
2540            Error::from_raw(err)
2541        }
2542    }
2543
2544    /// Process the committed queue
2545    ///
2546    /// Processes all items in the committed queue after a successful commit.
2547    pub fn process_committed_queue(
2548        &mut self,
2549        committed_queue: &mut CommittedQueue,
2550        new_revnum: crate::Revnum,
2551        rev_date: Option<&str>,
2552        rev_author: Option<&str>,
2553    ) -> Result<(), Error<'static>> {
2554        let pool = apr::Pool::new();
2555
2556        let rev_date_cstr = rev_date.map(std::ffi::CString::new).transpose()?;
2557        let rev_author_cstr = rev_author.map(std::ffi::CString::new).transpose()?;
2558
2559        unsafe {
2560            let err = subversion_sys::svn_wc_process_committed_queue2(
2561                committed_queue.as_mut_ptr(),
2562                self.ptr,
2563                new_revnum.0,
2564                rev_date_cstr
2565                    .as_ref()
2566                    .map_or(std::ptr::null(), |s| s.as_ptr()),
2567                rev_author_cstr
2568                    .as_ref()
2569                    .map_or(std::ptr::null(), |s| s.as_ptr()),
2570                None,                 // cancel_func
2571                std::ptr::null_mut(), // cancel_baton
2572                pool.as_mut_ptr(),
2573            );
2574            Error::from_raw(err)
2575        }
2576    }
2577
2578    /// Add a lock to the working copy
2579    ///
2580    /// Adds lock information for the given path.
2581    pub fn add_lock(
2582        &mut self,
2583        local_abspath: &std::path::Path,
2584        lock: &Lock,
2585    ) -> Result<(), Error<'static>> {
2586        let pool = apr::Pool::new();
2587        let path_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
2588
2589        unsafe {
2590            let err = subversion_sys::svn_wc_add_lock2(
2591                self.ptr,
2592                path_cstr.as_ptr(),
2593                lock.as_ptr(),
2594                pool.as_mut_ptr(),
2595            );
2596            Error::from_raw(err)
2597        }
2598    }
2599
2600    /// Remove a lock from the working copy
2601    ///
2602    /// Removes lock information for the given path.
2603    pub fn remove_lock(&mut self, local_abspath: &std::path::Path) -> Result<(), Error<'static>> {
2604        let pool = apr::Pool::new();
2605        let path_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
2606
2607        unsafe {
2608            let err = subversion_sys::svn_wc_remove_lock2(
2609                self.ptr,
2610                path_cstr.as_ptr(),
2611                pool.as_mut_ptr(),
2612            );
2613            Error::from_raw(err)
2614        }
2615    }
2616
2617    /// Crop a working copy subtree to a specified depth
2618    ///
2619    /// This function will remove any items that exceed the specified depth.
2620    /// For example, cropping to Depth::Files will remove any subdirectories.
2621    pub fn crop_tree(
2622        &mut self,
2623        local_abspath: &std::path::Path,
2624        depth: crate::Depth,
2625        cancel_func: Option<&dyn Fn() -> Result<(), Error<'static>>>,
2626    ) -> Result<(), Error<'static>> {
2627        let pool = apr::Pool::new();
2628        let path = local_abspath.to_str().unwrap();
2629        let path_cstr = crate::dirent::to_absolute_cstring(path)?;
2630
2631        let cancel_baton = cancel_func
2632            .map(box_cancel_baton_borrowed)
2633            .unwrap_or(std::ptr::null_mut());
2634
2635        let ret = unsafe {
2636            subversion_sys::svn_wc_crop_tree2(
2637                self.ptr,
2638                path_cstr.as_ptr(),
2639                depth.into(),
2640                if cancel_func.is_some() {
2641                    Some(crate::wrap_cancel_func)
2642                } else {
2643                    None
2644                },
2645                cancel_baton,
2646                None,                 // notify_func - not commonly used for crop
2647                std::ptr::null_mut(), // notify_baton
2648                pool.as_mut_ptr(),
2649            )
2650        };
2651
2652        // Free callback baton
2653        if !cancel_baton.is_null() {
2654            unsafe { drop_cancel_baton_borrowed(cancel_baton) };
2655        }
2656
2657        Error::from_raw(ret)
2658    }
2659
2660    /// Resolve a conflict on a working copy path
2661    ///
2662    /// This is the most advanced conflict resolution function, allowing
2663    /// specification of which conflict to resolve and how to resolve it.
2664    pub fn resolved_conflict(
2665        &mut self,
2666        local_abspath: &std::path::Path,
2667        depth: crate::Depth,
2668        resolve_text: bool,
2669        resolve_property: Option<&str>,
2670        resolve_tree: bool,
2671        conflict_choice: ConflictChoice,
2672        cancel_func: Option<&dyn Fn() -> Result<(), Error<'static>>>,
2673    ) -> Result<(), Error<'static>> {
2674        let pool = apr::Pool::new();
2675        let path = local_abspath.to_str().unwrap();
2676        let path_cstr = crate::dirent::to_absolute_cstring(path)?;
2677
2678        let prop_cstr = resolve_property.map(|p| std::ffi::CString::new(p).unwrap());
2679        let prop_ptr = prop_cstr.as_ref().map_or(std::ptr::null(), |p| p.as_ptr());
2680
2681        let cancel_baton = cancel_func
2682            .map(box_cancel_baton_borrowed)
2683            .unwrap_or(std::ptr::null_mut());
2684
2685        let ret = unsafe {
2686            subversion_sys::svn_wc_resolved_conflict5(
2687                self.ptr,
2688                path_cstr.as_ptr(),
2689                depth.into(),
2690                resolve_text.into(),
2691                prop_ptr,
2692                resolve_tree.into(),
2693                conflict_choice.into(),
2694                if cancel_func.is_some() {
2695                    Some(crate::wrap_cancel_func)
2696                } else {
2697                    None
2698                },
2699                cancel_baton,
2700                None,                 // notify_func
2701                std::ptr::null_mut(), // notify_baton
2702                pool.as_mut_ptr(),
2703            )
2704        };
2705
2706        // Free callback baton
2707        if !cancel_baton.is_null() {
2708            unsafe {
2709                drop(Box::from_raw(
2710                    cancel_baton as *mut Box<dyn Fn() -> Result<(), Error<'static>>>,
2711                ))
2712            };
2713        }
2714
2715        Error::from_raw(ret)
2716    }
2717
2718    /// Add a path from disk to version control
2719    ///
2720    /// This is the modern version that adds an existing on-disk item to version control.
2721    pub fn add_from_disk(
2722        &mut self,
2723        local_abspath: &std::path::Path,
2724        props: Option<&std::collections::HashMap<String, Vec<u8>>>,
2725        skip_checks: bool,
2726        notify_func: Option<&dyn Fn(&Notify)>,
2727    ) -> Result<(), Error<'static>> {
2728        let path = local_abspath.to_str().unwrap();
2729        let path_cstr = crate::dirent::to_absolute_cstring(path)?;
2730        let pool = apr::Pool::new();
2731
2732        // Build the props APR hash if provided
2733        let props_hash = if let Some(props) = props {
2734            let mut hash = apr::hash::Hash::new(&pool);
2735            for (key, value) in props {
2736                let svn_str = crate::svn_string_helpers::svn_string_ncreate(value, &pool);
2737                unsafe {
2738                    hash.insert(key.as_bytes(), svn_str as *mut std::ffi::c_void);
2739                }
2740            }
2741            unsafe { hash.as_mut_ptr() }
2742        } else {
2743            std::ptr::null_mut()
2744        };
2745
2746        let notify_baton = notify_func
2747            .map(|f| box_notify_baton_borrowed(f))
2748            .unwrap_or(std::ptr::null_mut());
2749
2750        let ret = unsafe {
2751            subversion_sys::svn_wc_add_from_disk3(
2752                self.ptr,
2753                path_cstr.as_ptr(),
2754                props_hash,
2755                skip_checks as i32,
2756                if notify_func.is_some() {
2757                    Some(wrap_notify_func)
2758                } else {
2759                    None
2760                },
2761                notify_baton,
2762                pool.as_mut_ptr(),
2763            )
2764        };
2765
2766        if !notify_baton.is_null() {
2767            unsafe { drop_notify_baton_borrowed(notify_baton) };
2768        }
2769
2770        Error::from_raw(ret)
2771    }
2772
2773    /// Add a file to the working copy with contents and properties sourced from
2774    /// a repository.
2775    ///
2776    /// This is used by update/switch/merge operations to schedule a file for
2777    /// addition when its contents are already available (e.g. from a network
2778    /// fetch), rather than reading them from disk.
2779    ///
2780    /// * `local_abspath` — absolute path where the new file will live.
2781    /// * `new_base_contents` — stream providing the pristine (base) file
2782    ///   contents.
2783    /// * `new_contents` — optional stream providing the working-copy contents;
2784    ///   pass `None` to use `new_base_contents` as the working copy contents.
2785    /// * `new_base_props` — pristine properties for the file.
2786    /// * `new_props` — optional working-copy property overrides; pass `None` to
2787    ///   use `new_base_props` as the working-copy properties.
2788    /// * `copyfrom_url` / `copyfrom_rev` — if the file was copied, its source
2789    ///   URL and revision; pass `None`/`-1` for a plain add.
2790    /// * `cancel_func` — optional cancellation callback.
2791    ///
2792    /// Wraps `svn_wc_add_repos_file4`.
2793    pub fn add_repos_file(
2794        &mut self,
2795        local_abspath: &std::path::Path,
2796        new_base_contents: &mut crate::io::Stream,
2797        new_contents: Option<&mut crate::io::Stream>,
2798        new_base_props: &std::collections::HashMap<String, Vec<u8>>,
2799        new_props: Option<&std::collections::HashMap<String, Vec<u8>>>,
2800        copyfrom_url: Option<&str>,
2801        copyfrom_rev: crate::Revnum,
2802        cancel_func: Option<Box<dyn Fn() -> Result<(), Error<'static>>>>,
2803    ) -> Result<(), Error<'static>> {
2804        let path_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
2805        let copyfrom_url_cstr = copyfrom_url
2806            .map(|u| std::ffi::CString::new(u).expect("copyfrom_url must be valid UTF-8"));
2807
2808        let scratch_pool = apr::Pool::new();
2809
2810        // Helper: build an apr_hash_t<const char*, svn_string_t*> from a HashMap.
2811        let build_props_hash = |props: &std::collections::HashMap<String, Vec<u8>>,
2812                                pool: &apr::Pool|
2813         -> *mut apr_sys::apr_hash_t {
2814            let mut hash = apr::hash::Hash::new(pool);
2815            for (name, value) in props {
2816                let svn_str = crate::svn_string_helpers::svn_string_ncreate(value, pool);
2817                unsafe {
2818                    hash.insert(name.as_bytes(), svn_str as *mut std::ffi::c_void);
2819                }
2820            }
2821            unsafe { hash.as_mut_ptr() }
2822        };
2823
2824        let base_props_ptr = build_props_hash(new_base_props, &scratch_pool);
2825        let props_ptr: *mut apr_sys::apr_hash_t = match new_props {
2826            Some(p) => build_props_hash(p, &scratch_pool),
2827            None => std::ptr::null_mut(),
2828        };
2829
2830        let has_cancel = cancel_func.is_some();
2831        let cancel_baton = cancel_func
2832            .map(box_cancel_baton)
2833            .unwrap_or(std::ptr::null_mut());
2834
2835        let err = unsafe {
2836            let e = subversion_sys::svn_wc_add_repos_file4(
2837                self.ptr,
2838                path_cstr.as_ptr(),
2839                new_base_contents.as_mut_ptr(),
2840                new_contents
2841                    .map(|s| s.as_mut_ptr())
2842                    .unwrap_or(std::ptr::null_mut()),
2843                base_props_ptr,
2844                props_ptr,
2845                copyfrom_url_cstr
2846                    .as_ref()
2847                    .map_or(std::ptr::null(), |c| c.as_ptr()),
2848                copyfrom_rev.0,
2849                if has_cancel {
2850                    Some(crate::wrap_cancel_func)
2851                } else {
2852                    None
2853                },
2854                cancel_baton,
2855                scratch_pool.as_mut_ptr(),
2856            );
2857            if has_cancel && !cancel_baton.is_null() {
2858                drop_cancel_baton(cancel_baton);
2859            }
2860            e
2861        };
2862
2863        Error::from_raw(err)
2864    }
2865
2866    /// Move a file or directory within the working copy
2867    pub fn move_path(
2868        &mut self,
2869        src_abspath: &std::path::Path,
2870        dst_abspath: &std::path::Path,
2871        metadata_only: bool,
2872        _allow_mixed_revisions: bool,
2873        cancel_func: Option<&dyn Fn() -> Result<(), Error<'static>>>,
2874        notify_func: Option<&dyn Fn(&Notify)>,
2875    ) -> Result<(), Error<'static>> {
2876        let src = src_abspath.to_str().unwrap();
2877        let src_cstr = crate::dirent::to_absolute_cstring(src)?;
2878        let dst = dst_abspath.to_str().unwrap();
2879        let dst_cstr = crate::dirent::to_absolute_cstring(dst)?;
2880        let pool = apr::Pool::new();
2881
2882        let cancel_baton = cancel_func
2883            .map(box_cancel_baton_borrowed)
2884            .unwrap_or(std::ptr::null_mut());
2885
2886        let notify_baton = notify_func
2887            .map(|f| box_notify_baton_borrowed(f))
2888            .unwrap_or(std::ptr::null_mut());
2889
2890        let ret = unsafe {
2891            subversion_sys::svn_wc_move(
2892                self.ptr,
2893                src_cstr.as_ptr(),
2894                dst_cstr.as_ptr(),
2895                metadata_only.into(),
2896                if cancel_func.is_some() {
2897                    Some(crate::wrap_cancel_func)
2898                } else {
2899                    None
2900                },
2901                cancel_baton,
2902                if notify_func.is_some() {
2903                    Some(wrap_notify_func)
2904                } else {
2905                    None
2906                },
2907                notify_baton,
2908                pool.as_mut_ptr(),
2909            )
2910        };
2911
2912        if !cancel_baton.is_null() {
2913            unsafe {
2914                drop(Box::from_raw(
2915                    cancel_baton as *mut Box<dyn Fn() -> Result<(), Error<'static>>>,
2916                ))
2917            };
2918        }
2919        if !notify_baton.is_null() {
2920            unsafe { drop_notify_baton_borrowed(notify_baton) };
2921        }
2922
2923        Error::from_raw(ret)
2924    }
2925
2926    /// Get an editor for switching the working copy to a different URL
2927    ///
2928    /// The returned editor borrows from the context and must not outlive it.
2929    pub fn get_switch_editor<'s>(
2930        &'s mut self,
2931        anchor_abspath: &str,
2932        target_basename: &str,
2933        switch_url: &str,
2934        options: SwitchEditorOptions,
2935    ) -> Result<(crate::delta::WrapEditor<'s>, crate::Revnum), crate::Error<'s>> {
2936        let anchor_abspath_cstr = crate::dirent::to_absolute_cstring(anchor_abspath)?;
2937        let target_basename_cstr = std::ffi::CString::new(target_basename)?;
2938        let switch_url_cstr = std::ffi::CString::new(switch_url)?;
2939        let diff3_cmd_cstr = options.diff3_cmd.map(std::ffi::CString::new).transpose()?;
2940
2941        let result_pool = apr::Pool::new();
2942
2943        // Create preserved extensions array
2944        let preserved_exts_cstrs: Vec<std::ffi::CString> = options
2945            .preserved_exts
2946            .iter()
2947            .map(|&s| std::ffi::CString::new(s))
2948            .collect::<Result<Vec<_>, _>>()?;
2949        let preserved_exts_apr = if preserved_exts_cstrs.is_empty() {
2950            std::ptr::null()
2951        } else {
2952            let mut arr = apr::tables::TypedArray::<*const i8>::new(
2953                &result_pool,
2954                preserved_exts_cstrs.len() as i32,
2955            );
2956            for cstr in &preserved_exts_cstrs {
2957                arr.push(cstr.as_ptr());
2958            }
2959            unsafe { arr.as_ptr() }
2960        };
2961        let mut target_revision: subversion_sys::svn_revnum_t = 0;
2962        let mut editor_ptr: *const subversion_sys::svn_delta_editor_t = std::ptr::null();
2963        let mut edit_baton: *mut std::ffi::c_void = std::ptr::null_mut();
2964
2965        // Create batons for callbacks
2966        let has_fetch_dirents = options.fetch_dirents_func.is_some();
2967        let fetch_dirents_baton = options
2968            .fetch_dirents_func
2969            .map(|f| box_fetch_dirents_baton(f))
2970            .unwrap_or(std::ptr::null_mut());
2971        let has_conflict = options.conflict_func.is_some();
2972        let conflict_baton = options
2973            .conflict_func
2974            .map(|f| box_conflict_baton(f))
2975            .unwrap_or(std::ptr::null_mut());
2976        let has_external = options.external_func.is_some();
2977        let external_baton = options
2978            .external_func
2979            .map(|f| box_external_baton(f))
2980            .unwrap_or(std::ptr::null_mut());
2981        let has_cancel = options.cancel_func.is_some();
2982        let cancel_baton = options
2983            .cancel_func
2984            .map(box_cancel_baton)
2985            .unwrap_or(std::ptr::null_mut());
2986        let has_notify = options.notify_func.is_some();
2987        let notify_baton = options
2988            .notify_func
2989            .map(|f| box_notify_baton(f))
2990            .unwrap_or(std::ptr::null_mut());
2991
2992        let err = with_tmp_pool(|scratch_pool| unsafe {
2993            svn_result(subversion_sys::svn_wc_get_switch_editor4(
2994                &mut editor_ptr,
2995                &mut edit_baton,
2996                &mut target_revision,
2997                self.ptr,
2998                anchor_abspath_cstr.as_ptr(),
2999                target_basename_cstr.as_ptr(),
3000                switch_url_cstr.as_ptr(),
3001                if options.use_commit_times { 1 } else { 0 },
3002                options.depth.into(),
3003                if options.depth_is_sticky { 1 } else { 0 },
3004                if options.allow_unver_obstructions {
3005                    1
3006                } else {
3007                    0
3008                },
3009                if options.server_performs_filtering {
3010                    1
3011                } else {
3012                    0
3013                },
3014                diff3_cmd_cstr
3015                    .as_ref()
3016                    .map_or(std::ptr::null(), |c| c.as_ptr()),
3017                preserved_exts_apr,
3018                if has_fetch_dirents {
3019                    Some(wrap_fetch_dirents_func)
3020                } else {
3021                    None
3022                },
3023                fetch_dirents_baton,
3024                if has_conflict {
3025                    Some(wrap_conflict_func)
3026                } else {
3027                    None
3028                },
3029                conflict_baton,
3030                if has_external {
3031                    Some(wrap_external_func)
3032                } else {
3033                    None
3034                },
3035                external_baton,
3036                if has_cancel {
3037                    Some(crate::wrap_cancel_func)
3038                } else {
3039                    None
3040                },
3041                cancel_baton,
3042                if has_notify {
3043                    Some(wrap_notify_func)
3044                } else {
3045                    None
3046                },
3047                notify_baton,
3048                result_pool.as_mut_ptr(),
3049                scratch_pool.as_mut_ptr(),
3050            ))
3051        });
3052
3053        err?;
3054
3055        // Store callback batons with their droppers so they're properly cleaned up
3056        let mut batons = Vec::new();
3057        if !fetch_dirents_baton.is_null() {
3058            batons.push((
3059                fetch_dirents_baton,
3060                drop_fetch_dirents_baton as crate::delta::DropperFn,
3061            ));
3062        }
3063        if !conflict_baton.is_null() {
3064            batons.push((
3065                conflict_baton,
3066                drop_conflict_baton as crate::delta::DropperFn,
3067            ));
3068        }
3069        if !external_baton.is_null() {
3070            batons.push((
3071                external_baton,
3072                drop_external_baton as crate::delta::DropperFn,
3073            ));
3074        }
3075        if !cancel_baton.is_null() {
3076            batons.push((cancel_baton, drop_cancel_baton as crate::delta::DropperFn));
3077        }
3078        if !notify_baton.is_null() {
3079            batons.push((notify_baton, drop_notify_baton as crate::delta::DropperFn));
3080        }
3081
3082        // Reuse the existing WrapEditor from delta module
3083        let editor = crate::delta::WrapEditor {
3084            editor: editor_ptr,
3085            baton: edit_baton,
3086            _pool: apr::PoolHandle::owned(result_pool),
3087            callback_batons: batons,
3088        };
3089
3090        Ok((
3091            editor,
3092            crate::Revnum::from_raw(target_revision).unwrap_or_default(),
3093        ))
3094    }
3095
3096    /// Get an editor for showing differences in the working copy.
3097    ///
3098    /// The `callbacks` parameter receives diff events as the returned editor
3099    /// is driven.  The returned editor borrows from the context and must not
3100    /// outlive it.
3101    pub fn get_diff_editor<'s>(
3102        &'s mut self,
3103        anchor_abspath: &str,
3104        target_abspath: &str,
3105        callbacks: &'s mut dyn DiffCallbacks,
3106        use_text_base: bool,
3107        depth: crate::Depth,
3108        ignore_ancestry: bool,
3109        show_copies_as_adds: bool,
3110        use_git_diff_format: bool,
3111    ) -> Result<crate::delta::WrapEditor<'s>, crate::Error<'s>> {
3112        let anchor_abspath_cstr = crate::dirent::to_absolute_cstring(anchor_abspath)?;
3113        let target_abspath_cstr = crate::dirent::to_absolute_cstring(target_abspath)?;
3114
3115        let result_pool = apr::Pool::new();
3116        let mut editor_ptr: *const subversion_sys::svn_delta_editor_t = std::ptr::null();
3117        let mut edit_baton: *mut std::ffi::c_void = std::ptr::null_mut();
3118
3119        // Heap-allocate the callbacks struct and baton so they outlive this call.
3120        let c_callbacks = Box::new(make_diff_callbacks4());
3121        let c_callbacks_ptr = &*c_callbacks as *const subversion_sys::svn_wc_diff_callbacks4_t;
3122
3123        // The baton is a pointer to a fat pointer (`&mut dyn DiffCallbacks`).
3124        let cb_baton: Box<*mut dyn DiffCallbacks> = Box::new(callbacks as *mut dyn DiffCallbacks);
3125        let cb_baton_ptr = &*cb_baton as *const *mut dyn DiffCallbacks as *mut std::ffi::c_void;
3126
3127        let err = with_tmp_pool(|scratch_pool| unsafe {
3128            svn_result(subversion_sys::svn_wc_get_diff_editor6(
3129                &mut editor_ptr,
3130                &mut edit_baton,
3131                self.ptr,
3132                anchor_abspath_cstr.as_ptr(),
3133                target_abspath_cstr.as_ptr(),
3134                depth.into(),
3135                if ignore_ancestry { 1 } else { 0 },
3136                if show_copies_as_adds { 1 } else { 0 },
3137                if use_git_diff_format { 1 } else { 0 },
3138                if use_text_base { 1 } else { 0 },
3139                0,                // reverse_order
3140                0,                // server_performs_filtering
3141                std::ptr::null(), // changelist_filter
3142                c_callbacks_ptr,
3143                cb_baton_ptr,
3144                None,                 // cancel_func
3145                std::ptr::null_mut(), // cancel_baton
3146                result_pool.as_mut_ptr(),
3147                scratch_pool.as_mut_ptr(),
3148            ))
3149        });
3150
3151        err?;
3152
3153        unsafe fn drop_callbacks(ptr: *mut std::ffi::c_void) {
3154            let _ = Box::from_raw(ptr as *mut subversion_sys::svn_wc_diff_callbacks4_t);
3155        }
3156        unsafe fn drop_baton(ptr: *mut std::ffi::c_void) {
3157            let _ = Box::from_raw(ptr as *mut *mut dyn DiffCallbacks);
3158        }
3159
3160        let batons: Vec<(*mut std::ffi::c_void, crate::delta::DropperFn)> = vec![
3161            (
3162                Box::into_raw(c_callbacks) as *mut std::ffi::c_void,
3163                drop_callbacks as crate::delta::DropperFn,
3164            ),
3165            (
3166                Box::into_raw(cb_baton) as *mut std::ffi::c_void,
3167                drop_baton as crate::delta::DropperFn,
3168            ),
3169        ];
3170
3171        let editor = crate::delta::WrapEditor {
3172            editor: editor_ptr,
3173            baton: edit_baton,
3174            _pool: apr::PoolHandle::owned(result_pool),
3175            callback_batons: batons,
3176        };
3177
3178        Ok(editor)
3179    }
3180
3181    /// Delete a path from version control
3182    pub fn delete(
3183        &mut self,
3184        local_abspath: &std::path::Path,
3185        keep_local: bool,
3186        delete_unversioned_target: bool,
3187        cancel_func: Option<&dyn Fn() -> Result<(), Error<'static>>>,
3188        notify_func: Option<&dyn Fn(&Notify)>,
3189    ) -> Result<(), Error<'static>> {
3190        let path = local_abspath.to_str().unwrap();
3191        let path_cstr = crate::dirent::to_absolute_cstring(path)?;
3192        let pool = apr::Pool::new();
3193
3194        let cancel_baton = cancel_func
3195            .map(box_cancel_baton_borrowed)
3196            .unwrap_or(std::ptr::null_mut());
3197
3198        let notify_baton = notify_func
3199            .map(|f| box_notify_baton_borrowed(f))
3200            .unwrap_or(std::ptr::null_mut());
3201
3202        let ret = unsafe {
3203            subversion_sys::svn_wc_delete4(
3204                self.ptr,
3205                path_cstr.as_ptr(),
3206                keep_local.into(),
3207                delete_unversioned_target.into(),
3208                if cancel_func.is_some() {
3209                    Some(crate::wrap_cancel_func)
3210                } else {
3211                    None
3212                },
3213                cancel_baton,
3214                if notify_func.is_some() {
3215                    Some(wrap_notify_func)
3216                } else {
3217                    None
3218                },
3219                notify_baton,
3220                pool.as_mut_ptr(),
3221            )
3222        };
3223
3224        if !cancel_baton.is_null() {
3225            unsafe {
3226                drop(Box::from_raw(
3227                    cancel_baton as *mut Box<dyn Fn() -> Result<(), Error<'static>>>,
3228                ))
3229            };
3230        }
3231        if !notify_baton.is_null() {
3232            unsafe { drop_notify_baton_borrowed(notify_baton) };
3233        }
3234
3235        Error::from_raw(ret)
3236    }
3237
3238    /// Get a versioned property value from a working copy path
3239    ///
3240    /// Retrieves the value of property @a name for @a local_abspath.
3241    /// Returns None if the property doesn't exist.
3242    pub fn prop_get(
3243        &mut self,
3244        local_abspath: &std::path::Path,
3245        name: &str,
3246    ) -> Result<Option<Vec<u8>>, Error<'_>> {
3247        let path = local_abspath.to_str().unwrap();
3248        let path_cstr = crate::dirent::to_absolute_cstring(path)?;
3249        let name_cstr = std::ffi::CString::new(name).unwrap();
3250        let result_pool = apr::Pool::new();
3251        let scratch_pool = apr::Pool::new();
3252
3253        let mut value: *const subversion_sys::svn_string_t = std::ptr::null();
3254
3255        let err = unsafe {
3256            subversion_sys::svn_wc_prop_get2(
3257                &mut value,
3258                self.ptr,
3259                path_cstr.as_ptr(),
3260                name_cstr.as_ptr(),
3261                result_pool.as_mut_ptr(),
3262                scratch_pool.as_mut_ptr(),
3263            )
3264        };
3265
3266        Error::from_raw(err)?;
3267
3268        if value.is_null() {
3269            Ok(None)
3270        } else {
3271            Ok(Some(Vec::from(unsafe {
3272                std::slice::from_raw_parts((*value).data as *const u8, (*value).len)
3273            })))
3274        }
3275    }
3276
3277    /// Set a versioned property on a working copy path
3278    ///
3279    /// Sets property @a name to @a value on @a local_abspath.
3280    /// If @a value is None, deletes the property.
3281    pub fn prop_set(
3282        &mut self,
3283        local_abspath: &std::path::Path,
3284        name: &str,
3285        value: Option<&[u8]>,
3286        depth: crate::Depth,
3287        skip_checks: bool,
3288        changelist_filter: Option<&[&str]>,
3289        cancel_func: Option<&dyn Fn() -> Result<(), Error<'static>>>,
3290        notify_func: Option<&dyn Fn(&Notify)>,
3291    ) -> Result<(), Error<'static>> {
3292        let path = local_abspath.to_str().unwrap();
3293        let path_cstr = crate::dirent::to_absolute_cstring(path)?;
3294        let name_cstr = std::ffi::CString::new(name).unwrap();
3295        let scratch_pool = apr::Pool::new();
3296
3297        // Create svn_string_t for the value if provided
3298        let value_svn = value.map(|v| crate::string::BStr::from_bytes(v, &scratch_pool));
3299        let value_ptr = value_svn
3300            .as_ref()
3301            .map(|v| v.as_ptr())
3302            .unwrap_or(std::ptr::null());
3303
3304        // Convert changelist_filter if provided
3305        let changelist_cstrings: Vec<_> = changelist_filter
3306            .map(|lists| {
3307                lists
3308                    .iter()
3309                    .map(|l| std::ffi::CString::new(*l).unwrap())
3310                    .collect()
3311            })
3312            .unwrap_or_default();
3313
3314        let changelist_array = if changelist_filter.is_some() {
3315            let mut array = apr::tables::TypedArray::<*const i8>::new(
3316                &scratch_pool,
3317                changelist_cstrings.len() as i32,
3318            );
3319            for cstring in &changelist_cstrings {
3320                array.push(cstring.as_ptr());
3321            }
3322            unsafe { array.as_ptr() }
3323        } else {
3324            std::ptr::null()
3325        };
3326
3327        let cancel_baton = cancel_func
3328            .map(box_cancel_baton_borrowed)
3329            .unwrap_or(std::ptr::null_mut());
3330
3331        let notify_baton = notify_func
3332            .map(|f| box_notify_baton_borrowed(f))
3333            .unwrap_or(std::ptr::null_mut());
3334
3335        let err = unsafe {
3336            subversion_sys::svn_wc_prop_set4(
3337                self.ptr,
3338                path_cstr.as_ptr(),
3339                name_cstr.as_ptr(),
3340                value_ptr,
3341                depth.into(),
3342                skip_checks.into(),
3343                changelist_array,
3344                if cancel_func.is_some() {
3345                    Some(crate::wrap_cancel_func)
3346                } else {
3347                    None
3348                },
3349                cancel_baton,
3350                if notify_func.is_some() {
3351                    Some(wrap_notify_func)
3352                } else {
3353                    None
3354                },
3355                notify_baton,
3356                scratch_pool.as_mut_ptr(),
3357            )
3358        };
3359
3360        if !cancel_baton.is_null() {
3361            unsafe {
3362                drop(Box::from_raw(
3363                    cancel_baton as *mut Box<dyn Fn() -> Result<(), Error<'static>>>,
3364                ))
3365            };
3366        }
3367        if !notify_baton.is_null() {
3368            unsafe { drop_notify_baton_borrowed(notify_baton) };
3369        }
3370
3371        Error::from_raw(err)
3372    }
3373
3374    /// List all versioned properties on a working copy path
3375    ///
3376    /// Returns a HashMap of all properties set on @a local_abspath.
3377    pub fn prop_list(
3378        &mut self,
3379        local_abspath: &std::path::Path,
3380    ) -> Result<std::collections::HashMap<String, Vec<u8>>, Error<'_>> {
3381        let path = local_abspath.to_str().unwrap();
3382        let path_cstr = crate::dirent::to_absolute_cstring(path)?;
3383        let result_pool = apr::Pool::new();
3384        let scratch_pool = apr::Pool::new();
3385
3386        let mut props: *mut apr::hash::apr_hash_t = std::ptr::null_mut();
3387
3388        let err = unsafe {
3389            subversion_sys::svn_wc_prop_list2(
3390                &mut props,
3391                self.ptr,
3392                path_cstr.as_ptr(),
3393                result_pool.as_mut_ptr(),
3394                scratch_pool.as_mut_ptr(),
3395            )
3396        };
3397
3398        Error::from_raw(err)?;
3399
3400        let prop_hash = unsafe { crate::props::PropHash::from_ptr(props) };
3401        Ok(prop_hash.to_hashmap())
3402    }
3403
3404    /// Get property differences for a working copy path
3405    ///
3406    /// Returns the property changes (propchanges) and original properties
3407    /// for a given path in the working copy. The propchanges represent
3408    /// modifications made in the working copy but not yet committed.
3409    ///
3410    /// # Arguments
3411    ///
3412    /// * `local_abspath` - Absolute path to the working copy item
3413    ///
3414    /// # Returns
3415    ///
3416    /// Returns a tuple of:
3417    /// * `Vec<PropChange>` - Array of property changes
3418    /// * `Option<HashMap<String, Vec<u8>>>` - Hash of original (pristine) properties (None if no properties)
3419    ///
3420    /// # Errors
3421    ///
3422    /// Returns an error if:
3423    /// * The path is not a valid working copy path
3424    /// * There are issues accessing the working copy database
3425    ///
3426    /// # Example
3427    ///
3428    /// ```rust,no_run
3429    /// # use subversion::wc::Context;
3430    /// # fn example() -> Result<(), subversion::Error> {
3431    /// let mut ctx = Context::new()?;
3432    /// let path = std::path::Path::new("/path/to/wc/file.txt");
3433    /// let (changes, original_props) = ctx.get_prop_diffs(path)?;
3434    /// for change in changes {
3435    ///     println!("Property {} changed", change.name);
3436    /// }
3437    /// # Ok(())
3438    /// # }
3439    /// ```
3440    pub fn get_prop_diffs(
3441        &mut self,
3442        local_abspath: &std::path::Path,
3443    ) -> Result<
3444        (
3445            Vec<PropChange>,
3446            Option<std::collections::HashMap<String, Vec<u8>>>,
3447        ),
3448        Error<'_>,
3449    > {
3450        let path = local_abspath.to_str().unwrap();
3451        let path_cstr = crate::dirent::to_absolute_cstring(path)?;
3452        let result_pool = apr::Pool::new();
3453        let scratch_pool = apr::Pool::new();
3454
3455        let mut propchanges: *mut apr::tables::apr_array_header_t = std::ptr::null_mut();
3456        let mut original_props: *mut apr::hash::apr_hash_t = std::ptr::null_mut();
3457
3458        let err = unsafe {
3459            subversion_sys::svn_wc_get_prop_diffs2(
3460                &mut propchanges,
3461                &mut original_props,
3462                self.ptr,
3463                path_cstr.as_ptr(),
3464                result_pool.as_mut_ptr(),
3465                scratch_pool.as_mut_ptr(),
3466            )
3467        };
3468
3469        Error::from_raw(err)?;
3470
3471        // Convert propchanges array to Vec<PropChange>
3472        // Note: The array contains svn_prop_t structs directly, not pointers
3473        let changes = if propchanges.is_null() {
3474            Vec::new()
3475        } else {
3476            let array = unsafe {
3477                apr::tables::TypedArray::<subversion_sys::svn_prop_t>::from_ptr(propchanges)
3478            };
3479            array
3480                .iter()
3481                .map(|prop| unsafe {
3482                    if prop.name.is_null() {
3483                        panic!("Encountered null prop.name in propchanges array");
3484                    }
3485                    let name = std::ffi::CStr::from_ptr(prop.name)
3486                        .to_str()
3487                        .expect("Property name is not valid UTF-8")
3488                        .to_owned();
3489                    let value = if prop.value.is_null() {
3490                        None
3491                    } else {
3492                        Some(crate::svn_string_helpers::to_vec(&*prop.value))
3493                    };
3494                    PropChange { name, value }
3495                })
3496                .collect()
3497        };
3498
3499        // Convert original_props hash to HashMap, or None if null
3500        let original = if original_props.is_null() {
3501            None
3502        } else {
3503            let prop_hash = unsafe { crate::props::PropHash::from_ptr(original_props) };
3504            Some(prop_hash.to_hashmap())
3505        };
3506
3507        Ok((changes, original))
3508    }
3509
3510    /// Read the kind (type) of a node in the working copy
3511    ///
3512    /// Returns the kind of node at @a local_abspath, which can be a file,
3513    /// directory, symlink, etc.
3514    ///
3515    /// If @a show_deleted is true, will show deleted nodes.
3516    /// If @a show_hidden is true, will show hidden nodes.
3517    pub fn read_kind(
3518        &mut self,
3519        local_abspath: &std::path::Path,
3520        show_deleted: bool,
3521        show_hidden: bool,
3522    ) -> Result<crate::NodeKind, Error<'static>> {
3523        let path = local_abspath.to_str().unwrap();
3524        let path_cstr = crate::dirent::to_absolute_cstring(path)?;
3525        let scratch_pool = apr::Pool::new();
3526
3527        let mut kind: subversion_sys::svn_node_kind_t =
3528            subversion_sys::svn_node_kind_t_svn_node_none;
3529
3530        let err = unsafe {
3531            subversion_sys::svn_wc_read_kind2(
3532                &mut kind,
3533                self.ptr,
3534                path_cstr.as_ptr(),
3535                show_deleted.into(),
3536                show_hidden.into(),
3537                scratch_pool.as_mut_ptr(),
3538            )
3539        };
3540
3541        Error::from_raw(err)?;
3542        Ok(kind.into())
3543    }
3544
3545    /// Check if a path is a working copy root
3546    ///
3547    /// Returns true if @a local_abspath is the root of a working copy.
3548    /// A working copy root is the top-level directory containing the .svn
3549    /// administrative directory.
3550    pub fn is_wc_root(&mut self, local_abspath: &std::path::Path) -> Result<bool, Error<'static>> {
3551        let path = local_abspath.to_str().unwrap();
3552        let path_cstr = crate::dirent::to_absolute_cstring(path)?;
3553        let scratch_pool = apr::Pool::new();
3554
3555        let mut wc_root: i32 = 0;
3556
3557        let err = unsafe {
3558            subversion_sys::svn_wc_is_wc_root2(
3559                &mut wc_root,
3560                self.ptr,
3561                path_cstr.as_ptr(),
3562                scratch_pool.as_mut_ptr(),
3563            )
3564        };
3565
3566        Error::from_raw(err)?;
3567        Ok(wc_root != 0)
3568    }
3569
3570    /// Exclude a versioned directory from a working copy
3571    ///
3572    /// This function removes @a local_abspath from the working copy, but
3573    /// unlike delete, keeps it in the repository. This is useful for
3574    /// sparse checkouts - excluding subtrees you don't need locally.
3575    ///
3576    /// The excluded item will not appear in status output and will not
3577    /// be included in commits. Use update with depth=infinity to bring
3578    /// it back.
3579    ///
3580    /// @a local_abspath must be a versioned directory.
3581    pub fn exclude(
3582        &mut self,
3583        local_abspath: &std::path::Path,
3584        cancel_func: Option<&dyn Fn() -> Result<(), Error<'static>>>,
3585        notify_func: Option<&dyn Fn(&Notify)>,
3586    ) -> Result<(), Error<'static>> {
3587        let path = local_abspath.to_str().unwrap();
3588        let path_cstr = crate::dirent::to_absolute_cstring(path)?;
3589        let scratch_pool = apr::Pool::new();
3590
3591        let cancel_baton = cancel_func
3592            .map(box_cancel_baton_borrowed)
3593            .unwrap_or(std::ptr::null_mut());
3594
3595        let notify_baton = notify_func
3596            .map(|f| box_notify_baton_borrowed(f))
3597            .unwrap_or(std::ptr::null_mut());
3598
3599        let err = unsafe {
3600            subversion_sys::svn_wc_exclude(
3601                self.ptr,
3602                path_cstr.as_ptr(),
3603                if cancel_func.is_some() {
3604                    Some(crate::wrap_cancel_func)
3605                } else {
3606                    None
3607                },
3608                cancel_baton,
3609                if notify_func.is_some() {
3610                    Some(wrap_notify_func)
3611                } else {
3612                    None
3613                },
3614                notify_baton,
3615                scratch_pool.as_mut_ptr(),
3616            )
3617        };
3618
3619        // Free callback batons after synchronous operation completes
3620        if !cancel_baton.is_null() {
3621            unsafe {
3622                drop(Box::from_raw(
3623                    cancel_baton as *mut &dyn Fn() -> Result<(), Error<'static>>,
3624                ));
3625            }
3626        }
3627        if !notify_baton.is_null() {
3628            unsafe {
3629                drop(Box::from_raw(notify_baton as *mut &dyn Fn(&Notify)));
3630            }
3631        }
3632
3633        Error::from_raw(err)
3634    }
3635
3636    /// Diff a working copy path against its base revision.
3637    ///
3638    /// Calls `callbacks` for each changed file or directory found under
3639    /// `target_abspath` up to the given `depth`.  This is a working-copy-only
3640    /// diff (base vs. working); it does not contact the repository.
3641    ///
3642    /// Wraps `svn_wc_diff6`.
3643    pub fn diff(
3644        &mut self,
3645        target_abspath: &std::path::Path,
3646        options: &DiffOptions,
3647        callbacks: &mut dyn DiffCallbacks,
3648    ) -> Result<(), Error<'static>> {
3649        let target_cstr = crate::dirent::to_absolute_cstring(target_abspath)?;
3650
3651        let scratch_pool = apr::Pool::new();
3652
3653        // Build the changelist filter array (NULL means no filter).
3654        let changelist_cstrs: Vec<std::ffi::CString> = options
3655            .changelists
3656            .iter()
3657            .map(|cl| std::ffi::CString::new(cl.as_str()).expect("changelist is valid UTF-8"))
3658            .collect();
3659        let mut changelist_arr =
3660            apr::tables::TypedArray::<*const i8>::new(&scratch_pool, changelist_cstrs.len() as i32);
3661        for s in &changelist_cstrs {
3662            changelist_arr.push(s.as_ptr());
3663        }
3664        let changelist_filter: *const apr_sys::apr_array_header_t =
3665            if options.changelists.is_empty() {
3666                std::ptr::null()
3667            } else {
3668                unsafe { changelist_arr.as_ptr() }
3669            };
3670
3671        let c_callbacks = make_diff_callbacks4();
3672
3673        // The baton is a pointer to a fat pointer (`&mut dyn DiffCallbacks`).
3674        let mut cb_ref: &mut dyn DiffCallbacks = callbacks;
3675        let baton = &mut cb_ref as *mut &mut dyn DiffCallbacks as *mut std::ffi::c_void;
3676
3677        with_tmp_pool(|scratch| unsafe {
3678            svn_result(subversion_sys::svn_wc_diff6(
3679                self.ptr,
3680                target_cstr.as_ptr(),
3681                &c_callbacks,
3682                baton,
3683                options.depth.into(),
3684                options.ignore_ancestry as i32,
3685                options.show_copies_as_adds as i32,
3686                options.use_git_diff_format as i32,
3687                changelist_filter,
3688                None,                 // cancel_func
3689                std::ptr::null_mut(), // cancel_baton
3690                scratch.as_mut_ptr(),
3691            ))
3692        })
3693    }
3694
3695    /// Merge two file revisions into a working copy file.
3696    ///
3697    /// Merges the differences between `left_abspath` and `right_abspath` into
3698    /// the versioned working copy file at `target_abspath`.
3699    ///
3700    /// Returns a tuple `(content_outcome, props_state)`:
3701    /// - `content_outcome`: whether the file content was unchanged, merged, or conflicted.
3702    /// - `props_state`: notify state for property changes (may be `Inapplicable` when
3703    ///   `prop_diff` is empty).
3704    ///
3705    /// Wraps `svn_wc_merge5`.
3706    pub fn merge(
3707        &mut self,
3708        left_abspath: &std::path::Path,
3709        right_abspath: &std::path::Path,
3710        target_abspath: &std::path::Path,
3711        left_label: Option<&str>,
3712        right_label: Option<&str>,
3713        target_label: Option<&str>,
3714        prop_diff: &[PropChange],
3715        options: &MergeOptions,
3716    ) -> Result<(MergeOutcome, NotifyState), Error<'static>> {
3717        let left_cstr = crate::dirent::to_absolute_cstring(left_abspath)?;
3718        let right_cstr = crate::dirent::to_absolute_cstring(right_abspath)?;
3719        let target_cstr = crate::dirent::to_absolute_cstring(target_abspath)?;
3720
3721        let left_label_cstr =
3722            left_label.map(|s| std::ffi::CString::new(s).expect("label must be valid UTF-8"));
3723        let right_label_cstr =
3724            right_label.map(|s| std::ffi::CString::new(s).expect("label must be valid UTF-8"));
3725        let target_label_cstr =
3726            target_label.map(|s| std::ffi::CString::new(s).expect("label must be valid UTF-8"));
3727
3728        let diff3_cstr = options
3729            .diff3_cmd
3730            .as_deref()
3731            .map(|s| std::ffi::CString::new(s).expect("diff3_cmd must be valid UTF-8"));
3732
3733        let scratch_pool = apr::Pool::new();
3734
3735        // Build the prop_diff array.
3736        // Keep CStrings alive for the duration of the C call.
3737        let prop_name_cstrs: Vec<std::ffi::CString> = prop_diff
3738            .iter()
3739            .map(|c| std::ffi::CString::new(c.name.as_str()).expect("prop name valid UTF-8"))
3740            .collect();
3741        let mut prop_diff_typed = apr::tables::TypedArray::<subversion_sys::svn_prop_t>::new(
3742            &scratch_pool,
3743            prop_diff.len() as i32,
3744        );
3745        for (change, name_cstr) in prop_diff.iter().zip(prop_name_cstrs.iter()) {
3746            prop_diff_typed.push(subversion_sys::svn_prop_t {
3747                name: name_cstr.as_ptr(),
3748                value: if let Some(v) = &change.value {
3749                    crate::svn_string_helpers::svn_string_ncreate(v, &scratch_pool)
3750                } else {
3751                    std::ptr::null()
3752                },
3753            });
3754        }
3755        let prop_diff_arr: *const apr_sys::apr_array_header_t = if prop_diff.is_empty() {
3756            std::ptr::null()
3757        } else {
3758            unsafe { prop_diff_typed.as_ptr() }
3759        };
3760
3761        // Build the merge_options array.
3762        // Keep CStrings alive for the duration of the C call.
3763        let merge_opt_cstrs: Vec<std::ffi::CString> = options
3764            .merge_options
3765            .iter()
3766            .map(|s| std::ffi::CString::new(s.as_str()).expect("merge option valid UTF-8"))
3767            .collect();
3768        let mut merge_opts_typed =
3769            apr::tables::TypedArray::<*const i8>::new(&scratch_pool, merge_opt_cstrs.len() as i32);
3770        for s in &merge_opt_cstrs {
3771            merge_opts_typed.push(s.as_ptr());
3772        }
3773        let merge_opts_arr: *const apr_sys::apr_array_header_t = if options.merge_options.is_empty()
3774        {
3775            std::ptr::null()
3776        } else {
3777            unsafe { merge_opts_typed.as_ptr() }
3778        };
3779
3780        let mut content_outcome = subversion_sys::svn_wc_merge_outcome_t_svn_wc_merge_no_merge;
3781        let mut props_state =
3782            subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_inapplicable;
3783
3784        let err = unsafe {
3785            svn_result(subversion_sys::svn_wc_merge5(
3786                &mut content_outcome,
3787                &mut props_state,
3788                self.ptr,
3789                left_cstr.as_ptr(),
3790                right_cstr.as_ptr(),
3791                target_cstr.as_ptr(),
3792                left_label_cstr
3793                    .as_ref()
3794                    .map_or(std::ptr::null(), |s| s.as_ptr()),
3795                right_label_cstr
3796                    .as_ref()
3797                    .map_or(std::ptr::null(), |s| s.as_ptr()),
3798                target_label_cstr
3799                    .as_ref()
3800                    .map_or(std::ptr::null(), |s| s.as_ptr()),
3801                std::ptr::null(), // left_version
3802                std::ptr::null(), // right_version
3803                options.dry_run as i32,
3804                diff3_cstr.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()),
3805                merge_opts_arr,
3806                std::ptr::null_mut(), // original_props (NULL = no prop merge)
3807                prop_diff_arr,
3808                None,                 // conflict_func
3809                std::ptr::null_mut(), // conflict_baton
3810                None,                 // cancel_func
3811                std::ptr::null_mut(), // cancel_baton
3812                scratch_pool.as_mut_ptr(),
3813            ))
3814        };
3815        err?;
3816
3817        Ok((content_outcome.into(), props_state.into()))
3818    }
3819
3820    /// Merge property changes into a working copy path.
3821    ///
3822    /// Applies `propchanges` to the versioned path `local_abspath`,
3823    /// starting from `baseprops` as the base property set.
3824    ///
3825    /// * `baseprops` — the "base" set of properties to compare against; pass
3826    ///   `None` to use an empty base.
3827    /// * `propchanges` — the list of changes to apply.
3828    /// * `dry_run` — if `true`, calculate and report conflicts but do not
3829    ///   modify the working copy.
3830    /// * `conflict_func` — optional callback invoked for each property conflict.
3831    ///   Return a [`crate::conflict::ConflictResult`] to resolve (or postpone)
3832    ///   the conflict.
3833    /// * `cancel_func` — optional callback checked periodically for
3834    ///   cancellation.
3835    ///
3836    /// Returns the resulting [`NotifyState`] describing whether the merge was
3837    /// clean, had conflicts, etc.
3838    ///
3839    /// Wraps `svn_wc_merge_props3`.
3840    pub fn merge_props(
3841        &mut self,
3842        local_abspath: &std::path::Path,
3843        baseprops: Option<&std::collections::HashMap<String, Vec<u8>>>,
3844        propchanges: &[PropChange],
3845        dry_run: bool,
3846        conflict_func: Option<
3847            Box<
3848                dyn Fn(
3849                    &crate::conflict::ConflictDescription,
3850                ) -> Result<crate::conflict::ConflictResult, Error<'static>>,
3851            >,
3852        >,
3853        cancel_func: Option<Box<dyn Fn() -> Result<(), Error<'static>>>>,
3854    ) -> Result<NotifyState, Error<'static>> {
3855        let path_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
3856
3857        let scratch_pool = apr::Pool::new();
3858
3859        // Build the baseprops hash (NULL = no base properties).
3860        let baseprops_hash_ptr: *mut apr_sys::apr_hash_t = if let Some(props) = baseprops {
3861            let mut hash = apr::hash::Hash::new(&scratch_pool);
3862            // Keep CStrings and svn_string_t values alive for the C call.
3863            let mut key_cstrs: Vec<std::ffi::CString> = Vec::with_capacity(props.len());
3864            let mut svn_strings: Vec<*mut subversion_sys::svn_string_t> =
3865                Vec::with_capacity(props.len());
3866            for (name, value) in props {
3867                key_cstrs
3868                    .push(std::ffi::CString::new(name.as_str()).expect("prop name valid UTF-8"));
3869                svn_strings.push(crate::svn_string_helpers::svn_string_ncreate(
3870                    value,
3871                    &scratch_pool,
3872                ));
3873            }
3874            for (cstr, svn_str) in key_cstrs.iter().zip(svn_strings.iter()) {
3875                unsafe {
3876                    hash.insert(cstr.as_bytes(), *svn_str as *mut std::ffi::c_void);
3877                }
3878            }
3879            unsafe { hash.as_mut_ptr() }
3880        } else {
3881            std::ptr::null_mut()
3882        };
3883
3884        // Build the propchanges array.
3885        let prop_name_cstrs: Vec<std::ffi::CString> = propchanges
3886            .iter()
3887            .map(|c| std::ffi::CString::new(c.name.as_str()).expect("prop name valid UTF-8"))
3888            .collect();
3889        let mut prop_changes_typed = apr::tables::TypedArray::<subversion_sys::svn_prop_t>::new(
3890            &scratch_pool,
3891            propchanges.len() as i32,
3892        );
3893        for (change, name_cstr) in propchanges.iter().zip(prop_name_cstrs.iter()) {
3894            prop_changes_typed.push(subversion_sys::svn_prop_t {
3895                name: name_cstr.as_ptr(),
3896                value: if let Some(v) = &change.value {
3897                    crate::svn_string_helpers::svn_string_ncreate(v, &scratch_pool)
3898                } else {
3899                    std::ptr::null()
3900                },
3901            });
3902        }
3903        let propchanges_arr: *const apr_sys::apr_array_header_t = if propchanges.is_empty() {
3904            std::ptr::null()
3905        } else {
3906            unsafe { prop_changes_typed.as_ptr() }
3907        };
3908
3909        let has_conflict = conflict_func.is_some();
3910        let conflict_baton = conflict_func
3911            .map(box_conflict_baton)
3912            .unwrap_or(std::ptr::null_mut());
3913        let has_cancel = cancel_func.is_some();
3914        let cancel_baton = cancel_func
3915            .map(box_cancel_baton)
3916            .unwrap_or(std::ptr::null_mut());
3917
3918        let mut state = subversion_sys::svn_wc_notify_state_t_svn_wc_notify_state_inapplicable;
3919
3920        let err = unsafe {
3921            let e = subversion_sys::svn_wc_merge_props3(
3922                &mut state,
3923                self.ptr,
3924                path_cstr.as_ptr(),
3925                std::ptr::null(), // left_version (informational, not required)
3926                std::ptr::null(), // right_version (informational, not required)
3927                baseprops_hash_ptr,
3928                propchanges_arr,
3929                dry_run as i32,
3930                if has_conflict {
3931                    Some(wrap_conflict_func)
3932                } else {
3933                    None
3934                },
3935                conflict_baton,
3936                if has_cancel {
3937                    Some(crate::wrap_cancel_func)
3938                } else {
3939                    None
3940                },
3941                cancel_baton,
3942                scratch_pool.as_mut_ptr(),
3943            );
3944            // Free the batons now that the C call has returned.
3945            if has_conflict && !conflict_baton.is_null() {
3946                drop_conflict_baton(conflict_baton);
3947            }
3948            if has_cancel && !cancel_baton.is_null() {
3949                drop_cancel_baton(cancel_baton);
3950            }
3951            e
3952        };
3953
3954        Error::from_raw(err)?;
3955        Ok(state.into())
3956    }
3957
3958    /// Revert local changes to `local_abspath`.
3959    ///
3960    /// The behaviour is controlled by `options`; see [`RevertOptions`] for
3961    /// details. Wraps `svn_wc_revert6`.
3962    pub fn revert(
3963        &mut self,
3964        local_abspath: &std::path::Path,
3965        options: &RevertOptions,
3966    ) -> Result<(), Error<'static>> {
3967        let path_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
3968
3969        let scratch_pool = apr::Pool::new();
3970
3971        // Build the changelist filter array (NULL = no filter).
3972        let changelist_cstrs: Vec<std::ffi::CString> = options
3973            .changelists
3974            .iter()
3975            .map(|cl| std::ffi::CString::new(cl.as_str()).expect("changelist is valid UTF-8"))
3976            .collect();
3977        let mut changelist_arr =
3978            apr::tables::TypedArray::<*const i8>::new(&scratch_pool, changelist_cstrs.len() as i32);
3979        for s in &changelist_cstrs {
3980            changelist_arr.push(s.as_ptr());
3981        }
3982        let changelist_filter: *const apr_sys::apr_array_header_t =
3983            if options.changelists.is_empty() {
3984                std::ptr::null()
3985            } else {
3986                unsafe { changelist_arr.as_ptr() }
3987            };
3988
3989        svn_result(unsafe {
3990            subversion_sys::svn_wc_revert6(
3991                self.ptr,
3992                path_cstr.as_ptr(),
3993                options.depth.into(),
3994                options.use_commit_times as i32,
3995                changelist_filter,
3996                options.clear_changelists as i32,
3997                options.metadata_only as i32,
3998                options.added_keep_local as i32,
3999                None,                 // cancel_func
4000                std::ptr::null_mut(), // cancel_baton
4001                None,                 // notify_func
4002                std::ptr::null_mut(), // notify_baton
4003                scratch_pool.as_mut_ptr(),
4004            )
4005        })
4006    }
4007
4008    /// Copy a versioned item from `src_abspath` to `dst_abspath` within the
4009    /// working copy, scheduling the destination for addition with history.
4010    ///
4011    /// The parent directory of `dst_abspath` must be under version control.
4012    /// `dst_abspath` itself must not already exist (unless `metadata_only` is
4013    /// `true`).
4014    ///
4015    /// If `metadata_only` is `true`, only the database is updated; the actual
4016    /// file or directory is not copied on disk.
4017    ///
4018    /// Wraps `svn_wc_copy3`.
4019    ///
4020    /// Note: `svn_wc_copy3` requires the parent directory of `dst_abspath` to
4021    /// be write-locked in this context.  Acquiring a write lock requires
4022    /// private SVN APIs; callers that need full copy support should use the
4023    /// higher-level `client::Context::copy()`.
4024    pub fn copy(
4025        &mut self,
4026        src_abspath: &std::path::Path,
4027        dst_abspath: &std::path::Path,
4028        metadata_only: bool,
4029    ) -> Result<(), Error<'static>> {
4030        let src_cstr = crate::dirent::to_absolute_cstring(src_abspath)?;
4031        let dst_cstr = crate::dirent::to_absolute_cstring(dst_abspath)?;
4032
4033        with_tmp_pool(|scratch| {
4034            svn_result(unsafe {
4035                subversion_sys::svn_wc_copy3(
4036                    self.ptr,
4037                    src_cstr.as_ptr(),
4038                    dst_cstr.as_ptr(),
4039                    metadata_only as i32,
4040                    None,                 // cancel_func
4041                    std::ptr::null_mut(), // cancel_baton
4042                    None,                 // notify_func
4043                    std::ptr::null_mut(), // notify_baton
4044                    scratch.as_mut_ptr(),
4045                )
4046            })
4047        })
4048    }
4049
4050    /// Assign or remove a changelist for `local_abspath`.
4051    ///
4052    /// If `changelist` is `Some(name)`, items are added to that changelist.
4053    /// If `changelist` is `None`, the changelist assignment is cleared.
4054    ///
4055    /// `depth` controls how far below `local_abspath` to recurse.
4056    /// `changelist_filter`, if non-empty, restricts changes to items that are
4057    /// currently in one of the listed changelists.
4058    ///
4059    /// Note: directories cannot be members of changelists.
4060    ///
4061    /// Wraps `svn_wc_set_changelist2`.
4062    pub fn set_changelist(
4063        &mut self,
4064        local_abspath: &std::path::Path,
4065        changelist: Option<&str>,
4066        depth: crate::Depth,
4067        changelist_filter: &[String],
4068    ) -> Result<(), Error<'static>> {
4069        let path_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
4070        let cl_cstr = changelist
4071            .map(|s| std::ffi::CString::new(s).expect("changelist name must be valid UTF-8"));
4072
4073        let scratch_pool = apr::Pool::new();
4074
4075        let filter_cstrs: Vec<std::ffi::CString> = changelist_filter
4076            .iter()
4077            .map(|s| std::ffi::CString::new(s.as_str()).expect("filter name must be valid UTF-8"))
4078            .collect();
4079        let mut filter_arr =
4080            apr::tables::TypedArray::<*const i8>::new(&scratch_pool, filter_cstrs.len() as i32);
4081        for s in &filter_cstrs {
4082            filter_arr.push(s.as_ptr());
4083        }
4084        let filter_ptr: *const apr_sys::apr_array_header_t = if changelist_filter.is_empty() {
4085            std::ptr::null()
4086        } else {
4087            unsafe { filter_arr.as_ptr() }
4088        };
4089
4090        svn_result(unsafe {
4091            subversion_sys::svn_wc_set_changelist2(
4092                self.ptr,
4093                path_cstr.as_ptr(),
4094                cl_cstr.as_ref().map_or(std::ptr::null(), |s| s.as_ptr()),
4095                depth.into(),
4096                filter_ptr,
4097                None,                 // cancel_func
4098                std::ptr::null_mut(), // cancel_baton
4099                None,                 // notify_func
4100                std::ptr::null_mut(), // notify_baton
4101                scratch_pool.as_mut_ptr(),
4102            )
4103        })
4104    }
4105
4106    /// Crawl `local_abspath` to `depth`, invoking `callback` for every path
4107    /// that belongs to a changelist.
4108    ///
4109    /// If `changelist_filter` is non-empty, only paths in one of those
4110    /// changelists are reported; pass an empty slice to report all changelists.
4111    ///
4112    /// `callback` receives `(path, changelist_name)` for each visited node.
4113    /// `changelist_name` is `None` for nodes that are not in any changelist;
4114    /// when `changelist_filter` is empty SVN visits every node in the tree
4115    /// (not just those with changelists), so `None` values are common.
4116    ///
4117    /// Wraps `svn_wc_get_changelists`.
4118    pub fn get_changelists(
4119        &mut self,
4120        local_abspath: &std::path::Path,
4121        depth: crate::Depth,
4122        changelist_filter: &[String],
4123        mut callback: impl FnMut(&str, Option<&str>) -> Result<(), Error<'static>>,
4124    ) -> Result<(), Error<'static>> {
4125        let path_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
4126
4127        let scratch_pool = apr::Pool::new();
4128
4129        let filter_cstrs: Vec<std::ffi::CString> = changelist_filter
4130            .iter()
4131            .map(|s| std::ffi::CString::new(s.as_str()).expect("filter name must be valid UTF-8"))
4132            .collect();
4133        let mut filter_arr =
4134            apr::tables::TypedArray::<*const i8>::new(&scratch_pool, filter_cstrs.len() as i32);
4135        for s in &filter_cstrs {
4136            filter_arr.push(s.as_ptr());
4137        }
4138        let filter_ptr: *const apr_sys::apr_array_header_t = if changelist_filter.is_empty() {
4139            std::ptr::null()
4140        } else {
4141            unsafe { filter_arr.as_ptr() }
4142        };
4143
4144        unsafe extern "C" fn cl_callback(
4145            baton: *mut std::ffi::c_void,
4146            path: *const std::os::raw::c_char,
4147            changelist: *const std::os::raw::c_char,
4148            pool: *mut apr_sys::apr_pool_t,
4149        ) -> *mut subversion_sys::svn_error_t {
4150            let cb = &mut *(baton
4151                as *mut &mut dyn FnMut(&str, Option<&str>) -> Result<(), Error<'static>>);
4152            let local_path = subversion_sys::svn_dirent_local_style(path, pool);
4153            let path = std::ffi::CStr::from_ptr(local_path).to_str().unwrap_or("");
4154            let cl = if changelist.is_null() {
4155                None
4156            } else {
4157                Some(std::ffi::CStr::from_ptr(changelist).to_str().unwrap_or(""))
4158            };
4159            match cb(path, cl) {
4160                Ok(()) => std::ptr::null_mut(),
4161                Err(e) => e.into_raw(),
4162            }
4163        }
4164
4165        let mut cb_ref: &mut dyn FnMut(&str, Option<&str>) -> Result<(), Error<'static>> =
4166            &mut callback;
4167        let baton = &mut cb_ref
4168            as *mut &mut dyn FnMut(&str, Option<&str>) -> Result<(), Error<'static>>
4169            as *mut std::ffi::c_void;
4170
4171        svn_result(unsafe {
4172            subversion_sys::svn_wc_get_changelists(
4173                self.ptr,
4174                path_cstr.as_ptr(),
4175                depth.into(),
4176                filter_ptr,
4177                Some(cl_callback),
4178                baton,
4179                None,                 // cancel_func
4180                std::ptr::null_mut(), // cancel_baton
4181                scratch_pool.as_mut_ptr(),
4182            )
4183        })
4184    }
4185
4186    /// Return the status of a single path in the working copy.
4187    ///
4188    /// The returned [`Status`] owns its APR pool and is valid for as long as
4189    /// it is held.  Use [`Context::walk_status`] to query an entire subtree.
4190    ///
4191    /// Wraps `svn_wc_status3`.
4192    pub fn status(
4193        &mut self,
4194        local_abspath: &std::path::Path,
4195    ) -> Result<Status<'static>, Error<'static>> {
4196        let path_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
4197        let result_pool = apr::Pool::new();
4198        let mut ptr: *mut subversion_sys::svn_wc_status3_t = std::ptr::null_mut();
4199        with_tmp_pool(|scratch| {
4200            svn_result(unsafe {
4201                subversion_sys::svn_wc_status3(
4202                    &mut ptr,
4203                    self.ptr,
4204                    path_cstr.as_ptr(),
4205                    result_pool.as_mut_ptr(),
4206                    scratch.as_mut_ptr(),
4207                )
4208            })
4209        })?;
4210        Ok(Status {
4211            ptr,
4212            _pool: apr::pool::PoolHandle::owned(result_pool),
4213        })
4214    }
4215
4216    /// Check whether `local_abspath` is a working-copy root and/or switched.
4217    ///
4218    /// Returns `(is_wcroot, is_switched, node_kind)`.
4219    ///
4220    /// Wraps `svn_wc_check_root`.
4221    pub fn check_root(
4222        &mut self,
4223        local_abspath: &std::path::Path,
4224    ) -> Result<(bool, bool, crate::NodeKind), Error<'static>> {
4225        let path_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
4226        let mut is_wcroot: subversion_sys::svn_boolean_t = 0;
4227        let mut is_switched: subversion_sys::svn_boolean_t = 0;
4228        let mut kind: subversion_sys::svn_node_kind_t =
4229            subversion_sys::svn_node_kind_t_svn_node_unknown;
4230        with_tmp_pool(|scratch| {
4231            svn_result(unsafe {
4232                subversion_sys::svn_wc_check_root(
4233                    &mut is_wcroot,
4234                    &mut is_switched,
4235                    &mut kind,
4236                    self.ptr,
4237                    path_cstr.as_ptr(),
4238                    scratch.as_mut_ptr(),
4239                )
4240            })
4241        })?;
4242        Ok((is_wcroot != 0, is_switched != 0, kind.into()))
4243    }
4244
4245    /// Restore a missing or replaced working-copy file from its base revision.
4246    ///
4247    /// If `use_commit_times` is `true`, the restored file's timestamp is set
4248    /// to the last-commit time rather than the current time.
4249    ///
4250    /// Wraps `svn_wc_restore`.
4251    pub fn restore(
4252        &mut self,
4253        local_abspath: &std::path::Path,
4254        use_commit_times: bool,
4255    ) -> Result<(), Error<'static>> {
4256        let path_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
4257        with_tmp_pool(|scratch| {
4258            svn_result(unsafe {
4259                subversion_sys::svn_wc_restore(
4260                    self.ptr,
4261                    path_cstr.as_ptr(),
4262                    use_commit_times as i32,
4263                    scratch.as_mut_ptr(),
4264                )
4265            })
4266        })
4267    }
4268
4269    /// Return the list of ignore patterns applying to `local_abspath`.
4270    ///
4271    /// Combines global patterns from the SVN configuration with any
4272    /// `svn:ignore` property set on the directory at `local_abspath`.
4273    /// Pass `NULL` for config to use the default configuration.
4274    ///
4275    /// Wraps `svn_wc_get_ignores2`.
4276    pub fn get_ignores(
4277        &mut self,
4278        local_abspath: &std::path::Path,
4279    ) -> Result<Vec<String>, Error<'static>> {
4280        let path_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
4281        let result_pool = apr::Pool::new();
4282        let mut patterns: *mut apr_sys::apr_array_header_t = std::ptr::null_mut();
4283        with_tmp_pool(|scratch| {
4284            svn_result(unsafe {
4285                subversion_sys::svn_wc_get_ignores2(
4286                    &mut patterns,
4287                    self.ptr,
4288                    path_cstr.as_ptr(),
4289                    std::ptr::null_mut(), // config — NULL uses defaults
4290                    result_pool.as_mut_ptr(),
4291                    scratch.as_mut_ptr(),
4292                )
4293            })
4294        })?;
4295        if patterns.is_null() {
4296            return Ok(Vec::new());
4297        }
4298        let result =
4299            unsafe { apr::tables::TypedArray::<*const std::os::raw::c_char>::from_ptr(patterns) }
4300                .iter()
4301                .map(|ptr| unsafe { std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned() })
4302                .collect();
4303        Ok(result)
4304    }
4305
4306    /// Remove `local_abspath` from revision control.
4307    ///
4308    /// The context must hold a write lock on the parent of `local_abspath`, or
4309    /// if that is a WC root then on `local_abspath` itself.
4310    ///
4311    /// If `local_abspath` is a file, all its metadata is removed from the
4312    /// administrative area.  If it is a directory, the administrative area is
4313    /// deleted recursively for the entire subtree.
4314    ///
4315    /// Only administrative data is removed unless `destroy_wf` is `true`, in
4316    /// which case the working file(s) and directories are also deleted from
4317    /// disk.  When `destroy_wf` is `true`, locally modified files are left in
4318    /// place and [`Error`] with `SVN_ERR_WC_LEFT_LOCAL_MOD` is returned.
4319    ///
4320    /// If `instant_error` is `true`, the function returns
4321    /// `SVN_ERR_WC_LEFT_LOCAL_MOD` as soon as a locally modified file is
4322    /// encountered; otherwise it finishes the traversal and returns the error
4323    /// afterwards.
4324    ///
4325    /// Wraps `svn_wc_remove_from_revision_control2`.
4326    pub fn remove_from_revision_control(
4327        &mut self,
4328        local_abspath: &std::path::Path,
4329        destroy_wf: bool,
4330        instant_error: bool,
4331    ) -> Result<(), Error<'static>> {
4332        let path_cstr = crate::dirent::to_absolute_cstring(local_abspath)?;
4333        with_tmp_pool(|scratch| {
4334            svn_result(unsafe {
4335                subversion_sys::svn_wc_remove_from_revision_control2(
4336                    self.ptr,
4337                    path_cstr.as_ptr(),
4338                    destroy_wf as i32,
4339                    instant_error as i32,
4340                    None,                 // cancel_func
4341                    std::ptr::null_mut(), // cancel_baton
4342                    scratch.as_mut_ptr(),
4343                )
4344            })
4345        })
4346    }
4347}
4348
4349/// Notify structure for callbacks
4350pub struct Notify {
4351    ptr: *const subversion_sys::svn_wc_notify_t,
4352}
4353
4354impl Notify {
4355    unsafe fn from_ptr(ptr: *const subversion_sys::svn_wc_notify_t) -> Self {
4356        Self { ptr }
4357    }
4358
4359    /// Get the action type
4360    pub fn action(&self) -> u32 {
4361        unsafe { (*self.ptr).action as u32 }
4362    }
4363
4364    /// Get the path
4365    pub fn path(&self) -> Option<&str> {
4366        unsafe {
4367            if (*self.ptr).path.is_null() {
4368                None
4369            } else {
4370                Some(std::ffi::CStr::from_ptr((*self.ptr).path).to_str().unwrap())
4371            }
4372        }
4373    }
4374
4375    /// Get the node kind
4376    pub fn kind(&self) -> crate::NodeKind {
4377        unsafe { (*self.ptr).kind.into() }
4378    }
4379
4380    /// Get the mime type
4381    pub fn mime_type(&self) -> Option<&str> {
4382        unsafe {
4383            if (*self.ptr).mime_type.is_null() {
4384                None
4385            } else {
4386                Some(
4387                    std::ffi::CStr::from_ptr((*self.ptr).mime_type)
4388                        .to_str()
4389                        .unwrap(),
4390                )
4391            }
4392        }
4393    }
4394
4395    /// Get the lock information
4396    pub fn lock(&self) -> Option<Lock> {
4397        unsafe {
4398            if (*self.ptr).lock.is_null() {
4399                None
4400            } else {
4401                Some(Lock::from_ptr((*self.ptr).lock))
4402            }
4403        }
4404    }
4405
4406    /// Get the error if notification indicates a failure
4407    pub fn err(&self) -> Option<Error<'_>> {
4408        unsafe {
4409            if (*self.ptr).err.is_null() {
4410                None
4411            } else {
4412                Some(Error::from_raw((*self.ptr).err).unwrap_err())
4413            }
4414        }
4415    }
4416
4417    /// Get the content state
4418    pub fn content_state(&self) -> u32 {
4419        unsafe { (*self.ptr).content_state as u32 }
4420    }
4421
4422    /// Get the property state
4423    pub fn prop_state(&self) -> u32 {
4424        unsafe { (*self.ptr).prop_state as u32 }
4425    }
4426
4427    /// Get the lock state
4428    pub fn lock_state(&self) -> u32 {
4429        unsafe { (*self.ptr).lock_state as u32 }
4430    }
4431
4432    /// Get the revision
4433    pub fn revision(&self) -> Option<crate::Revnum> {
4434        unsafe { crate::Revnum::from_raw((*self.ptr).revision) }
4435    }
4436
4437    /// Get the changelist name
4438    pub fn changelist_name(&self) -> Option<&str> {
4439        unsafe {
4440            if (*self.ptr).changelist_name.is_null() {
4441                None
4442            } else {
4443                Some(
4444                    std::ffi::CStr::from_ptr((*self.ptr).changelist_name)
4445                        .to_str()
4446                        .unwrap(),
4447                )
4448            }
4449        }
4450    }
4451
4452    /// Get the URL
4453    pub fn url(&self) -> Option<&str> {
4454        unsafe {
4455            if (*self.ptr).url.is_null() {
4456                None
4457            } else {
4458                Some(std::ffi::CStr::from_ptr((*self.ptr).url).to_str().unwrap())
4459            }
4460        }
4461    }
4462
4463    /// Get the path prefix
4464    pub fn path_prefix(&self) -> Option<&str> {
4465        unsafe {
4466            if (*self.ptr).path_prefix.is_null() {
4467                None
4468            } else {
4469                Some(
4470                    std::ffi::CStr::from_ptr((*self.ptr).path_prefix)
4471                        .to_str()
4472                        .unwrap(),
4473                )
4474            }
4475        }
4476    }
4477
4478    /// Get the property name
4479    pub fn prop_name(&self) -> Option<&str> {
4480        unsafe {
4481            if (*self.ptr).prop_name.is_null() {
4482                None
4483            } else {
4484                Some(
4485                    std::ffi::CStr::from_ptr((*self.ptr).prop_name)
4486                        .to_str()
4487                        .unwrap(),
4488                )
4489            }
4490        }
4491    }
4492
4493    /// Get the old revision (for updates)
4494    pub fn old_revision(&self) -> Option<crate::Revnum> {
4495        unsafe { crate::Revnum::from_raw((*self.ptr).old_revision) }
4496    }
4497
4498    /// Get the hunk original start line (for patch operations)
4499    pub fn hunk_original_start(&self) -> u64 {
4500        unsafe { (*self.ptr).hunk_original_start.into() }
4501    }
4502
4503    /// Get the hunk original length (for patch operations)
4504    pub fn hunk_original_length(&self) -> u64 {
4505        unsafe { (*self.ptr).hunk_original_length.into() }
4506    }
4507
4508    /// Get the hunk modified start line (for patch operations)
4509    pub fn hunk_modified_start(&self) -> u64 {
4510        unsafe { (*self.ptr).hunk_modified_start.into() }
4511    }
4512
4513    /// Get the hunk modified length (for patch operations)
4514    pub fn hunk_modified_length(&self) -> u64 {
4515        unsafe { (*self.ptr).hunk_modified_length.into() }
4516    }
4517
4518    /// Get the line at which a hunk was matched (for patch operations)
4519    pub fn hunk_matched_line(&self) -> u64 {
4520        unsafe { (*self.ptr).hunk_matched_line.into() }
4521    }
4522
4523    /// Get the fuzz factor the hunk was applied with (for patch operations)
4524    pub fn hunk_fuzz(&self) -> u64 {
4525        unsafe { (*self.ptr).hunk_fuzz.into() }
4526    }
4527}
4528
4529/// Wrapper for conflict resolver callbacks
4530extern "C" fn wrap_conflict_func(
4531    result: *mut *mut subversion_sys::svn_wc_conflict_result_t,
4532    description: *const subversion_sys::svn_wc_conflict_description2_t,
4533    baton: *mut std::ffi::c_void,
4534    result_pool: *mut apr_sys::apr_pool_t,
4535    _scratch_pool: *mut apr_sys::apr_pool_t,
4536) -> *mut subversion_sys::svn_error_t {
4537    if baton.is_null() || description.is_null() || result.is_null() {
4538        return std::ptr::null_mut();
4539    }
4540
4541    let callback = unsafe {
4542        &*(baton
4543            as *const Box<
4544                dyn Fn(
4545                    &crate::conflict::ConflictDescription,
4546                ) -> Result<crate::conflict::ConflictResult, Error<'static>>,
4547            >)
4548    };
4549
4550    // Convert C description to Rust type
4551    let desc = match unsafe { crate::conflict::ConflictDescription::from_raw(description) } {
4552        Ok(d) => d,
4553        Err(mut e) => return unsafe { e.detach() },
4554    };
4555
4556    match callback(&desc) {
4557        Ok(conflict_result) => {
4558            // Convert Rust result to C result
4559            unsafe {
4560                *result = conflict_result.to_raw(result_pool);
4561            }
4562            std::ptr::null_mut()
4563        }
4564        Err(mut e) => unsafe { e.detach() },
4565    }
4566}
4567
4568/// Wrapper for external update callbacks
4569extern "C" fn wrap_external_func(
4570    baton: *mut std::ffi::c_void,
4571    local_abspath: *const i8,
4572    old_val: *const subversion_sys::svn_string_t,
4573    new_val: *const subversion_sys::svn_string_t,
4574    depth: subversion_sys::svn_depth_t,
4575    _scratch_pool: *mut apr_sys::apr_pool_t,
4576) -> *mut subversion_sys::svn_error_t {
4577    if baton.is_null() || local_abspath.is_null() {
4578        return std::ptr::null_mut();
4579    }
4580
4581    let callback = unsafe {
4582        &*(baton
4583            as *const Box<
4584                dyn Fn(
4585                    &str,
4586                    Option<&str>,
4587                    Option<&str>,
4588                    crate::Depth,
4589                ) -> Result<(), Error<'static>>,
4590            >)
4591    };
4592
4593    let path_str = unsafe {
4594        std::ffi::CStr::from_ptr(local_abspath)
4595            .to_str()
4596            .unwrap_or("")
4597    };
4598
4599    let old_str = if old_val.is_null() {
4600        None
4601    } else {
4602        unsafe {
4603            let data = (*old_val).data as *const u8;
4604            let len = (*old_val).len;
4605            std::str::from_utf8(std::slice::from_raw_parts(data, len)).ok()
4606        }
4607    };
4608
4609    let new_str = if new_val.is_null() {
4610        None
4611    } else {
4612        unsafe {
4613            let data = (*new_val).data as *const u8;
4614            let len = (*new_val).len;
4615            std::str::from_utf8(std::slice::from_raw_parts(data, len)).ok()
4616        }
4617    };
4618
4619    let depth_enum = crate::Depth::from(depth);
4620
4621    match callback(path_str, old_str, new_str, depth_enum) {
4622        Ok(()) => std::ptr::null_mut(),
4623        Err(mut e) => unsafe { e.detach() },
4624    }
4625}
4626
4627/// Wrapper for notify callbacks
4628pub(crate) extern "C" fn wrap_notify_func(
4629    baton: *mut std::ffi::c_void,
4630    notify: *const subversion_sys::svn_wc_notify_t,
4631    _pool: *mut apr_sys::apr_pool_t,
4632) {
4633    if baton.is_null() || notify.is_null() {
4634        return;
4635    }
4636
4637    let callback = unsafe { &*(baton as *const Box<dyn Fn(&Notify)>) };
4638    let notify_struct = unsafe { Notify::from_ptr(notify) };
4639    callback(&notify_struct);
4640}
4641
4642/// Wrapper for fetch dirents callbacks
4643extern "C" fn wrap_fetch_dirents_func(
4644    baton: *mut std::ffi::c_void,
4645    dirents: *mut *mut apr_sys::apr_hash_t,
4646    repos_root_url: *const i8,
4647    repos_relpath: *const i8,
4648    result_pool: *mut apr_sys::apr_pool_t,
4649    _scratch_pool: *mut apr_sys::apr_pool_t,
4650) -> *mut subversion_sys::svn_error_t {
4651    if baton.is_null() || dirents.is_null() || repos_root_url.is_null() || repos_relpath.is_null() {
4652        return std::ptr::null_mut();
4653    }
4654
4655    let callback = unsafe {
4656        &*(baton
4657            as *const Box<
4658                dyn Fn(
4659                    &str,
4660                    &str,
4661                ) -> Result<
4662                    std::collections::HashMap<String, crate::DirEntry>,
4663                    Error<'static>,
4664                >,
4665            >)
4666    };
4667
4668    let root_url = unsafe {
4669        std::ffi::CStr::from_ptr(repos_root_url)
4670            .to_str()
4671            .unwrap_or("")
4672    };
4673
4674    let relpath = unsafe {
4675        std::ffi::CStr::from_ptr(repos_relpath)
4676            .to_str()
4677            .unwrap_or("")
4678    };
4679
4680    let pool = unsafe { apr::Pool::from_raw(result_pool) };
4681    match callback(root_url, relpath) {
4682        Ok(dirents_map) => {
4683            // Create apr_hash_t and populate it
4684            let mut hash = apr::hash::Hash::new(&pool);
4685            for (name, dirent) in dirents_map {
4686                // Create svn_dirent_t in the pool
4687                let svn_dirent = pool.alloc::<subversion_sys::svn_dirent_t>();
4688                unsafe {
4689                    let svn_dirent_ptr = (*svn_dirent).as_mut_ptr();
4690                    std::ptr::write_bytes(svn_dirent_ptr, 0, 1);
4691                    (*svn_dirent_ptr).kind = dirent.kind().into();
4692                    (*svn_dirent_ptr).size = dirent.size();
4693                    (*svn_dirent_ptr).has_props = if dirent.has_props() { 1 } else { 0 };
4694                    (*svn_dirent_ptr).created_rev = dirent.created_rev().map(|r| r.0).unwrap_or(-1);
4695                    (*svn_dirent_ptr).time = dirent.time().into();
4696                    if let Some(author) = dirent.last_author() {
4697                        (*svn_dirent_ptr).last_author = pool.pstrdup(author);
4698                    }
4699                    hash.insert(name.as_bytes(), svn_dirent_ptr as *mut std::ffi::c_void);
4700                }
4701            }
4702            unsafe {
4703                *dirents = hash.as_mut_ptr();
4704            }
4705            std::ptr::null_mut()
4706        }
4707        Err(mut e) => unsafe { e.detach() },
4708    }
4709}
4710
4711/// Represents a queue of committed items
4712pub struct CommittedQueue {
4713    ptr: *mut subversion_sys::svn_wc_committed_queue_t,
4714    _pool: apr::Pool<'static>,
4715}
4716
4717impl Default for CommittedQueue {
4718    fn default() -> Self {
4719        Self::new()
4720    }
4721}
4722
4723impl CommittedQueue {
4724    /// Create a new committed queue
4725    pub fn new() -> Self {
4726        let pool = apr::Pool::new();
4727        let ptr = unsafe { subversion_sys::svn_wc_committed_queue_create(pool.as_mut_ptr()) };
4728        Self { ptr, _pool: pool }
4729    }
4730
4731    pub(crate) fn as_mut_ptr(&mut self) -> *mut subversion_sys::svn_wc_committed_queue_t {
4732        self.ptr
4733    }
4734}
4735
4736/// Represents a lock in the working copy
4737pub struct Lock {
4738    ptr: *const subversion_sys::svn_lock_t,
4739    /// Pool for owned locks (created via Lock::new)
4740    _pool: Option<apr::Pool<'static>>,
4741}
4742
4743impl Lock {
4744    /// Create from a raw pointer (borrows, does not own the lock memory)
4745    pub fn from_ptr(ptr: *const subversion_sys::svn_lock_t) -> Self {
4746        Self { ptr, _pool: None }
4747    }
4748
4749    /// Create a new lock with the given path and token
4750    pub fn new(path: Option<&str>, token: Option<&[u8]>) -> Self {
4751        let pool = apr::Pool::new();
4752        let lock_ptr = unsafe { subversion_sys::svn_lock_create(pool.as_mut_ptr()) };
4753        if let Some(p) = path {
4754            let cstr = std::ffi::CString::new(p).unwrap();
4755            unsafe {
4756                (*lock_ptr).path = apr_sys::apr_pstrdup(pool.as_mut_ptr(), cstr.as_ptr());
4757            }
4758        }
4759        if let Some(t) = token {
4760            let cstr = std::ffi::CString::new(t).unwrap();
4761            unsafe {
4762                (*lock_ptr).token = apr_sys::apr_pstrdup(pool.as_mut_ptr(), cstr.as_ptr());
4763            }
4764        }
4765        Self {
4766            ptr: lock_ptr as *const _,
4767            _pool: Some(pool),
4768        }
4769    }
4770
4771    /// Get the raw pointer to the lock
4772    pub fn as_ptr(&self) -> *const subversion_sys::svn_lock_t {
4773        self.ptr
4774    }
4775
4776    /// Get the path this lock applies to
4777    pub fn path(&self) -> Option<&str> {
4778        unsafe {
4779            let p = (*self.ptr).path;
4780            if p.is_null() {
4781                None
4782            } else {
4783                Some(std::ffi::CStr::from_ptr(p).to_str().unwrap())
4784            }
4785        }
4786    }
4787
4788    /// Get the unique URI representing the lock token
4789    pub fn token(&self) -> Option<&str> {
4790        unsafe {
4791            let t = (*self.ptr).token;
4792            if t.is_null() {
4793                None
4794            } else {
4795                Some(std::ffi::CStr::from_ptr(t).to_str().unwrap())
4796            }
4797        }
4798    }
4799
4800    /// Get the username which owns the lock
4801    pub fn owner(&self) -> Option<&str> {
4802        unsafe {
4803            let o = (*self.ptr).owner;
4804            if o.is_null() {
4805                None
4806            } else {
4807                Some(std::ffi::CStr::from_ptr(o).to_str().unwrap())
4808            }
4809        }
4810    }
4811
4812    /// Get the optional description of the lock
4813    pub fn comment(&self) -> Option<&str> {
4814        unsafe {
4815            let c = (*self.ptr).comment;
4816            if c.is_null() {
4817                None
4818            } else {
4819                Some(std::ffi::CStr::from_ptr(c).to_str().unwrap())
4820            }
4821        }
4822    }
4823}
4824
4825/// Clean up a working copy
4826pub fn cleanup(
4827    wc_path: &std::path::Path,
4828    break_locks: bool,
4829    fix_recorded_timestamps: bool,
4830    clear_dav_cache: bool,
4831    vacuum_pristines: bool,
4832    _include_externals: bool,
4833) -> Result<(), Error<'static>> {
4834    let path_cstr = crate::dirent::to_absolute_cstring(wc_path)?;
4835
4836    with_tmp_pool(|pool| -> Result<(), Error<'static>> {
4837        let mut ctx = std::ptr::null_mut();
4838        with_tmp_pool(|scratch_pool| {
4839            let err = unsafe {
4840                subversion_sys::svn_wc_context_create(
4841                    &mut ctx,
4842                    std::ptr::null_mut(),
4843                    pool.as_mut_ptr(),
4844                    scratch_pool.as_mut_ptr(),
4845                )
4846            };
4847            svn_result(err)
4848        })?;
4849
4850        let err = unsafe {
4851            subversion_sys::svn_wc_cleanup4(
4852                ctx,
4853                path_cstr.as_ptr(),
4854                break_locks as i32,
4855                fix_recorded_timestamps as i32,
4856                clear_dav_cache as i32,
4857                vacuum_pristines as i32,
4858                None,                 // cancel_func
4859                std::ptr::null_mut(), // cancel_baton
4860                None,                 // notify_func
4861                std::ptr::null_mut(), // notify_baton
4862                pool.as_mut_ptr(),
4863            )
4864        };
4865        Error::from_raw(err)?;
4866        Ok(())
4867    })
4868}
4869
4870/// Get the working copy revision status
4871/// Add a path to version control
4872pub fn add(
4873    ctx: &mut Context,
4874    path: &std::path::Path,
4875    _depth: crate::Depth,
4876    force: bool,
4877    _no_ignore: bool,
4878    _no_autoprops: bool,
4879    _add_parents: bool,
4880) -> Result<(), Error<'static>> {
4881    let path_cstr = crate::dirent::to_absolute_cstring(path)?;
4882
4883    with_tmp_pool(|pool| unsafe {
4884        let err = subversion_sys::svn_wc_add_from_disk3(
4885            ctx.as_mut_ptr(),
4886            path_cstr.as_ptr(),
4887            std::ptr::null_mut(), // props (use auto-props if enabled)
4888            force as i32,
4889            None,                 // notify_func
4890            std::ptr::null_mut(), // notify_baton
4891            pool.as_mut_ptr(),
4892        );
4893        Error::from_raw(err)
4894    })
4895}
4896
4897/// Delete a path from version control
4898pub fn delete(
4899    ctx: &mut Context,
4900    path: &std::path::Path,
4901    keep_local: bool,
4902    delete_unversioned_target: bool,
4903) -> Result<(), Error<'static>> {
4904    let path_cstr = crate::dirent::to_absolute_cstring(path)?;
4905
4906    with_tmp_pool(|pool| unsafe {
4907        let err = subversion_sys::svn_wc_delete4(
4908            ctx.as_mut_ptr(),
4909            path_cstr.as_ptr(),
4910            keep_local as i32,
4911            delete_unversioned_target as i32,
4912            None,                 // cancel_func
4913            std::ptr::null_mut(), // cancel_baton
4914            None,                 // notify_func
4915            std::ptr::null_mut(), // notify_baton
4916            pool.as_mut_ptr(),
4917        );
4918        Error::from_raw(err)
4919    })
4920}
4921
4922/// Revert changes to a path
4923pub fn revert(
4924    ctx: &mut Context,
4925    path: &std::path::Path,
4926    depth: crate::Depth,
4927    use_commit_times: bool,
4928    clear_changelists: bool,
4929    metadata_only: bool,
4930) -> Result<(), Error<'static>> {
4931    let path_cstr = crate::dirent::to_absolute_cstring(path)?;
4932
4933    with_tmp_pool(|pool| unsafe {
4934        let err = subversion_sys::svn_wc_revert6(
4935            ctx.as_mut_ptr(),
4936            path_cstr.as_ptr(),
4937            depth.into(),
4938            use_commit_times as i32,
4939            std::ptr::null(), // changelists
4940            clear_changelists as i32,
4941            metadata_only as i32,
4942            1,                    // added_keep_local (keep added files)
4943            None,                 // cancel_func
4944            std::ptr::null_mut(), // cancel_baton
4945            None,                 // notify_func
4946            std::ptr::null_mut(), // notify_baton
4947            pool.as_mut_ptr(),
4948        );
4949        Error::from_raw(err)
4950    })
4951}
4952
4953/// Copy or move a path within the working copy
4954pub fn copy_or_move(
4955    ctx: &mut Context,
4956    src: &std::path::Path,
4957    dst: &std::path::Path,
4958    is_move: bool,
4959    metadata_only: bool,
4960) -> Result<(), Error<'static>> {
4961    let src_cstr = crate::dirent::to_absolute_cstring(src)?;
4962    let dst_cstr = crate::dirent::to_absolute_cstring(dst)?;
4963
4964    with_tmp_pool(|pool| unsafe {
4965        if is_move {
4966            let err = subversion_sys::svn_wc_move(
4967                ctx.as_mut_ptr(),
4968                src_cstr.as_ptr(),
4969                dst_cstr.as_ptr(),
4970                metadata_only as i32,
4971                None,                 // cancel_func
4972                std::ptr::null_mut(), // cancel_baton
4973                None,                 // notify_func
4974                std::ptr::null_mut(), // notify_baton
4975                pool.as_mut_ptr(),
4976            );
4977            Error::from_raw(err)
4978        } else {
4979            let err = subversion_sys::svn_wc_copy3(
4980                ctx.as_mut_ptr(),
4981                src_cstr.as_ptr(),
4982                dst_cstr.as_ptr(),
4983                metadata_only as i32,
4984                None,                 // cancel_func
4985                std::ptr::null_mut(), // cancel_baton
4986                None,                 // notify_func
4987                std::ptr::null_mut(), // notify_baton
4988                pool.as_mut_ptr(),
4989            );
4990            Error::from_raw(err)
4991        }
4992    })
4993}
4994
4995/// Resolve a conflict on a path
4996pub fn resolve_conflict(
4997    ctx: &mut Context,
4998    path: &std::path::Path,
4999    depth: crate::Depth,
5000    resolve_text: bool,
5001    _resolve_props: bool,
5002    resolve_tree: bool,
5003    conflict_choice: ConflictChoice,
5004) -> Result<(), Error<'static>> {
5005    let path_cstr = crate::dirent::to_absolute_cstring(path)?;
5006
5007    with_tmp_pool(|pool| unsafe {
5008        let err = subversion_sys::svn_wc_resolved_conflict5(
5009            ctx.as_mut_ptr(),
5010            path_cstr.as_ptr(),
5011            depth.into(),
5012            resolve_text as i32,
5013            std::ptr::null(), // resolve_prop (resolve all props if resolve_props is true)
5014            resolve_tree as i32,
5015            conflict_choice.into(),
5016            None,                 // cancel_func
5017            std::ptr::null_mut(), // cancel_baton
5018            None,                 // notify_func
5019            std::ptr::null_mut(), // notify_baton
5020            pool.as_mut_ptr(),
5021        );
5022        Error::from_raw(err)
5023    })
5024}
5025
5026/// Gets the revision status of a working copy.
5027///
5028/// Returns (min_revision, max_revision, is_switched, is_modified).
5029pub fn revision_status(
5030    wc_path: &std::path::Path,
5031    trail_url: Option<&str>,
5032    committed: bool,
5033) -> Result<(i64, i64, bool, bool), Error<'static>> {
5034    let path_cstr = crate::dirent::to_absolute_cstring(wc_path)?;
5035    let trail_cstr = trail_url.map(std::ffi::CString::new).transpose()?;
5036
5037    with_tmp_pool(|pool| -> Result<(i64, i64, bool, bool), Error<'static>> {
5038        let mut ctx = std::ptr::null_mut();
5039        with_tmp_pool(|scratch_pool| {
5040            let err = unsafe {
5041                subversion_sys::svn_wc_context_create(
5042                    &mut ctx,
5043                    std::ptr::null_mut(),
5044                    pool.as_mut_ptr(),
5045                    scratch_pool.as_mut_ptr(),
5046                )
5047            };
5048            svn_result(err)
5049        })?;
5050
5051        let mut status_ptr: *mut subversion_sys::svn_wc_revision_status_t = std::ptr::null_mut();
5052
5053        with_tmp_pool(|scratch_pool| {
5054            let err = unsafe {
5055                subversion_sys::svn_wc_revision_status2(
5056                    &mut status_ptr,
5057                    ctx,
5058                    path_cstr.as_ptr(),
5059                    trail_cstr.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()),
5060                    committed as i32,
5061                    None,                 // cancel_func
5062                    std::ptr::null_mut(), // cancel_baton
5063                    pool.as_mut_ptr(),
5064                    scratch_pool.as_mut_ptr(),
5065                )
5066            };
5067            Error::from_raw(err)
5068        })?;
5069
5070        if status_ptr.is_null() {
5071            return Err(Error::from(std::io::Error::other(
5072                "Failed to get revision status",
5073            )));
5074        }
5075
5076        let status = unsafe { *status_ptr };
5077        Ok((
5078            status.min_rev.into(),
5079            status.max_rev.into(),
5080            status.switched != 0,
5081            status.modified != 0,
5082        ))
5083    })
5084}
5085
5086// Note: Advanced conflict resolution functions like crop_tree and
5087// mark_resolved require more complex FFI bindings that are not currently
5088// implemented in the subversion-sys crate. The basic conflict detection
5089// via Context.conflicted() is available and working.
5090
5091/// Conflict resolution choice
5092#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5093#[repr(i32)]
5094pub enum ConflictChoice {
5095    /// Postpone resolution
5096    Postpone = subversion_sys::svn_wc_conflict_choice_t_svn_wc_conflict_choose_postpone,
5097    /// Choose the base revision
5098    Base = subversion_sys::svn_wc_conflict_choice_t_svn_wc_conflict_choose_base,
5099    /// Choose the theirs revision
5100    Theirs = subversion_sys::svn_wc_conflict_choice_t_svn_wc_conflict_choose_theirs_full,
5101    /// Choose the mine/working revision
5102    Mine = subversion_sys::svn_wc_conflict_choice_t_svn_wc_conflict_choose_mine_full,
5103    /// Choose the theirs file for conflicts
5104    TheirsConflict =
5105        subversion_sys::svn_wc_conflict_choice_t_svn_wc_conflict_choose_theirs_conflict,
5106    /// Choose the mine file for conflicts
5107    MineConflict = subversion_sys::svn_wc_conflict_choice_t_svn_wc_conflict_choose_mine_conflict,
5108    /// Merge the conflicted regions
5109    Merged = subversion_sys::svn_wc_conflict_choice_t_svn_wc_conflict_choose_merged,
5110}
5111
5112impl From<ConflictChoice> for subversion_sys::svn_wc_conflict_choice_t {
5113    fn from(choice: ConflictChoice) -> Self {
5114        choice as subversion_sys::svn_wc_conflict_choice_t
5115    }
5116}
5117
5118// Context methods for conflict resolution would go here when the
5119// underlying FFI bindings are properly implemented
5120
5121/// An external item definition parsed from svn:externals property.
5122#[derive(Debug, Clone)]
5123pub struct ExternalItem {
5124    /// The subdirectory into which this external should be checked out.
5125    pub target_dir: String,
5126    /// The URL to check out from (possibly relative).
5127    pub url: String,
5128    /// The revision to check out.
5129    pub revision: crate::Revision,
5130    /// The peg revision to use.
5131    pub peg_revision: crate::Revision,
5132}
5133
5134/// Parse an svn:externals property value into a list of external items.
5135///
5136/// The `parent_directory` is used for error messages and to resolve
5137/// relative URLs in the externals description.
5138pub fn parse_externals_description(
5139    parent_directory: &str,
5140    desc: &str,
5141    canonicalize_url: bool,
5142) -> Result<Vec<ExternalItem>, Error<'static>> {
5143    let pool = apr::Pool::new();
5144
5145    let parent_cstr = std::ffi::CString::new(parent_directory)
5146        .map_err(|_| Error::from_message("Invalid parent directory"))?;
5147    let desc_cstr = std::ffi::CString::new(desc)
5148        .map_err(|_| Error::from_message("Invalid externals description"))?;
5149
5150    unsafe {
5151        let mut externals_p: *mut apr_sys::apr_array_header_t = std::ptr::null_mut();
5152        let err = subversion_sys::svn_wc_parse_externals_description3(
5153            &mut externals_p,
5154            parent_cstr.as_ptr(),
5155            desc_cstr.as_ptr(),
5156            canonicalize_url.into(),
5157            pool.as_mut_ptr(),
5158        );
5159        svn_result(err)?;
5160
5161        if externals_p.is_null() {
5162            return Ok(Vec::new());
5163        }
5164
5165        let array =
5166            apr::tables::TypedArray::<*const subversion_sys::svn_wc_external_item2_t>::from_ptr(
5167                externals_p,
5168            );
5169
5170        let mut result = Vec::new();
5171        for item_ptr in array.iter() {
5172            let item = &*item_ptr;
5173
5174            let target_dir = if item.target_dir.is_null() {
5175                String::new()
5176            } else {
5177                std::ffi::CStr::from_ptr(item.target_dir)
5178                    .to_str()
5179                    .map_err(|_| Error::from_message("Invalid target_dir UTF-8"))?
5180                    .to_string()
5181            };
5182
5183            let url = if item.url.is_null() {
5184                String::new()
5185            } else {
5186                std::ffi::CStr::from_ptr(item.url)
5187                    .to_str()
5188                    .map_err(|_| Error::from_message("Invalid url UTF-8"))?
5189                    .to_string()
5190            };
5191
5192            unsafe fn convert_revision(
5193                rev: &subversion_sys::svn_opt_revision_t,
5194            ) -> crate::Revision {
5195                match rev.kind {
5196                    subversion_sys::svn_opt_revision_kind_svn_opt_revision_unspecified => {
5197                        crate::Revision::Unspecified
5198                    }
5199                    subversion_sys::svn_opt_revision_kind_svn_opt_revision_number => {
5200                        crate::Revision::Number(crate::Revnum(*rev.value.number.as_ref()))
5201                    }
5202                    subversion_sys::svn_opt_revision_kind_svn_opt_revision_date => {
5203                        crate::Revision::Date(*rev.value.date.as_ref())
5204                    }
5205                    subversion_sys::svn_opt_revision_kind_svn_opt_revision_head => {
5206                        crate::Revision::Head
5207                    }
5208                    _ => crate::Revision::Unspecified,
5209                }
5210            }
5211
5212            result.push(ExternalItem {
5213                target_dir,
5214                url,
5215                revision: convert_revision(&item.revision),
5216                peg_revision: convert_revision(&item.peg_revision),
5217            });
5218        }
5219
5220        Ok(result)
5221    }
5222}
5223
5224/// Return the global list of ignore patterns from SVN's default configuration.
5225///
5226/// This is equivalent to [`Context::get_ignores`] without a working copy path:
5227/// it returns only the patterns from the global SVN configuration (e.g.
5228/// `~/.subversion/config`'s `global-ignores` setting), without adding any
5229/// `svn:ignore` property patterns from a working copy directory.
5230///
5231/// Pass `NULL` for `config` to use the default on-disk configuration.
5232///
5233/// Wraps `svn_wc_get_default_ignores`.
5234pub fn get_default_ignores() -> Result<Vec<String>, crate::Error<'static>> {
5235    let result_pool = apr::Pool::new();
5236    let mut patterns: *mut apr_sys::apr_array_header_t = std::ptr::null_mut();
5237    svn_result(unsafe {
5238        subversion_sys::svn_wc_get_default_ignores(
5239            &mut patterns,
5240            std::ptr::null_mut(), // config — NULL uses defaults
5241            result_pool.as_mut_ptr(),
5242        )
5243    })?;
5244    if patterns.is_null() {
5245        return Ok(Vec::new());
5246    }
5247    let result =
5248        unsafe { apr::tables::TypedArray::<*const std::os::raw::c_char>::from_ptr(patterns) }
5249            .iter()
5250            .map(|ptr| unsafe { std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned() })
5251            .collect();
5252    Ok(result)
5253}
5254
5255/// Canonicalize and validate an SVN property value.
5256///
5257/// Checks that `propname` is valid for a node of `kind`, and returns a
5258/// canonicalized form of `propval`.  For example:
5259/// - `svn:executable`, `svn:needs-lock`, `svn:special` → value set to `"*"`
5260/// - `svn:keywords` → leading/trailing whitespace stripped
5261/// - `svn:ignore`, `svn:externals` → trailing newline added if missing
5262/// - `svn:mergeinfo` → normalized
5263///
5264/// Content-dependent checks (`svn:eol-style`, `svn:mime-type`) are
5265/// skipped; pass the returned value to [`Context::prop_set`] without
5266/// further validation.
5267///
5268/// Returns an error if the property name or value is invalid.
5269///
5270/// Wraps `svn_wc_canonicalize_svn_prop`.
5271pub fn canonicalize_svn_prop(
5272    propname: &str,
5273    propval: &[u8],
5274    path: &str,
5275    kind: crate::NodeKind,
5276) -> Result<Vec<u8>, crate::Error<'static>> {
5277    let pool = apr::Pool::new();
5278    let propname_c = std::ffi::CString::new(propname)
5279        .map_err(|_| crate::Error::from_message("property name contains interior NUL"))?;
5280    let path_c = crate::dirent::canonicalize_path_or_url(path)
5281        .map_err(|_| crate::Error::from_message("path contains interior NUL"))?;
5282
5283    let input = crate::string::BStr::from_bytes(propval, &pool);
5284    let mut output: *const subversion_sys::svn_string_t = std::ptr::null();
5285
5286    svn_result(unsafe {
5287        subversion_sys::svn_wc_canonicalize_svn_prop(
5288            &mut output,
5289            propname_c.as_ptr(),
5290            input.as_ptr(),
5291            path_c.as_ptr(),
5292            kind.into(),
5293            1,                    // skip_some_checks = true (no content/MIME inspection)
5294            None,                 // prop_getter — not needed when skipping checks
5295            std::ptr::null_mut(), // getter_baton
5296            pool.as_mut_ptr(),
5297        )
5298    })?;
5299
5300    if output.is_null() {
5301        return Ok(propval.to_vec());
5302    }
5303    let s = unsafe { &*output };
5304    Ok(unsafe { std::slice::from_raw_parts(s.data as *const u8, s.len).to_vec() })
5305}
5306
5307/// Send local text modifications for a versioned file through a delta editor.
5308///
5309/// Transmits the local modifications for the versioned file at `local_abspath`
5310/// through the provided file editor, then closes the file baton.
5311///
5312/// If `fulltext` is true, sends the untranslated copy as full-text; otherwise
5313/// sends it as an svndiff against the current text base.
5314///
5315/// Returns a tuple of (MD5 hex string, SHA-1 hex string) for the text base in
5316/// repository-normal form. The SHA-1 checksum corresponds to a copy stored
5317/// in the pristine text store.
5318///
5319/// # Errors
5320///
5321/// Returns `SVN_ERR_WC_CORRUPT_TEXT_BASE` if sending a diff and the recorded
5322/// checksum for the text-base doesn't match the current actual checksum.
5323///
5324/// Wraps `svn_wc_transmit_text_deltas3`.
5325///
5326/// # Example
5327///
5328/// This is typically used within custom commit operations where you have
5329/// a file editor from a delta operation and want to send working copy
5330/// contents through it.
5331pub fn transmit_text_deltas<'a>(
5332    wc_ctx: &mut Context,
5333    local_abspath: &str,
5334    fulltext: bool,
5335    file_editor: &crate::delta::WrapFileEditor<'a>,
5336) -> Result<(String, String), crate::Error<'static>> {
5337    let result_pool = apr::Pool::new();
5338    let scratch_pool = apr::Pool::new();
5339
5340    let local_abspath_c = crate::dirent::to_absolute_cstring(local_abspath)?;
5341
5342    let mut md5_checksum: *const subversion_sys::svn_checksum_t = std::ptr::null();
5343    let mut sha1_checksum: *const subversion_sys::svn_checksum_t = std::ptr::null();
5344
5345    let (editor_ptr, baton_ptr) = file_editor.as_raw_parts();
5346
5347    let err = unsafe {
5348        subversion_sys::svn_wc_transmit_text_deltas3(
5349            &mut md5_checksum,
5350            &mut sha1_checksum,
5351            wc_ctx.ptr,
5352            local_abspath_c.as_ptr(),
5353            if fulltext { 1 } else { 0 },
5354            editor_ptr,
5355            baton_ptr,
5356            result_pool.as_mut_ptr(),
5357            scratch_pool.as_mut_ptr(),
5358        )
5359    };
5360    svn_result(err)?;
5361
5362    let md5_hex = if md5_checksum.is_null() {
5363        String::new()
5364    } else {
5365        let checksum = crate::Checksum::from_raw(md5_checksum);
5366        checksum.to_hex(&result_pool)
5367    };
5368
5369    let sha1_hex = if sha1_checksum.is_null() {
5370        String::new()
5371    } else {
5372        let checksum = crate::Checksum::from_raw(sha1_checksum);
5373        checksum.to_hex(&result_pool)
5374    };
5375
5376    Ok((md5_hex, sha1_hex))
5377}
5378
5379/// Send local property modifications through a file delta editor.
5380///
5381/// Transmits all local property modifications for the file at `local_abspath`
5382/// using the file editor's change_prop method.
5383///
5384/// This is typically used in custom commit operations to send property changes
5385/// to a repository.
5386///
5387/// Wraps `svn_wc_transmit_prop_deltas2`.
5388pub fn transmit_prop_deltas_file<'a>(
5389    wc_ctx: &mut Context,
5390    local_abspath: &str,
5391    file_editor: &crate::delta::WrapFileEditor<'a>,
5392) -> Result<(), crate::Error<'static>> {
5393    let scratch_pool = apr::Pool::new();
5394    let local_abspath_c = crate::dirent::to_absolute_cstring(local_abspath)?;
5395
5396    let (editor_ptr, baton_ptr) = file_editor.as_raw_parts();
5397
5398    let err = unsafe {
5399        subversion_sys::svn_wc_transmit_prop_deltas2(
5400            wc_ctx.ptr,
5401            local_abspath_c.as_ptr(),
5402            editor_ptr,
5403            baton_ptr,
5404            scratch_pool.as_mut_ptr(),
5405        )
5406    };
5407    svn_result(err)?;
5408
5409    Ok(())
5410}
5411
5412/// Send local property modifications through a directory delta editor.
5413///
5414/// Transmits all local property modifications for the directory at `local_abspath`
5415/// using the directory editor's change_prop method.
5416///
5417/// This is typically used in custom commit operations to send property changes
5418/// to a repository.
5419///
5420/// Wraps `svn_wc_transmit_prop_deltas2`.
5421pub fn transmit_prop_deltas_dir<'a>(
5422    wc_ctx: &mut Context,
5423    local_abspath: &str,
5424    dir_editor: &crate::delta::WrapDirectoryEditor<'a>,
5425) -> Result<(), crate::Error<'static>> {
5426    let scratch_pool = apr::Pool::new();
5427    let local_abspath_c = crate::dirent::to_absolute_cstring(local_abspath)?;
5428
5429    let (editor_ptr, baton_ptr) = dir_editor.as_raw_parts();
5430
5431    let err = unsafe {
5432        subversion_sys::svn_wc_transmit_prop_deltas2(
5433            wc_ctx.ptr,
5434            local_abspath_c.as_ptr(),
5435            editor_ptr,
5436            baton_ptr,
5437            scratch_pool.as_mut_ptr(),
5438        )
5439    };
5440    svn_result(err)?;
5441
5442    Ok(())
5443}
5444
5445#[cfg(all(test, feature = "client", feature = "repos"))]
5446mod tests {
5447    use super::*;
5448    use std::path::{Path, PathBuf};
5449    use tempfile::tempdir;
5450
5451    /// Ensures the filesystem timestamp will differ from the current time.
5452    ///
5453    /// Subversion uses APR timestamps (apr_time_t) which have microsecond precision,
5454    /// not nanosecond. When files are modified very rapidly (within the same microsecond),
5455    /// SVN won't detect the change because the stored timestamp hasn't changed.
5456    ///
5457    /// This function sleeps for 2 milliseconds to guarantee the next file operation
5458    /// will have a different timestamp when truncated to microsecond precision.
5459    ///
5460    /// Use this in tests after creating/checking out a file and before modifying it
5461    /// when you need SVN to detect the modification.
5462    fn ensure_timestamp_rollover() {
5463        std::thread::sleep(std::time::Duration::from_micros(2000));
5464    }
5465
5466    /// Test fixture for a Subversion repository and working copy setup.
5467    struct SvnTestFixture {
5468        pub _repos_path: PathBuf,
5469        pub wc_path: PathBuf,
5470        pub url: String,
5471        pub client_ctx: crate::client::Context,
5472        pub temp_dir: tempfile::TempDir,
5473    }
5474
5475    impl SvnTestFixture {
5476        /// Creates a repository and checks out a working copy.
5477        fn new() -> Self {
5478            let temp_dir = tempfile::TempDir::new().unwrap();
5479            let repos_path = temp_dir.path().join("repos");
5480            let wc_path = temp_dir.path().join("wc");
5481
5482            // Create repository
5483            let _repos = crate::repos::Repos::create(&repos_path).unwrap();
5484
5485            // Prepare for checkout
5486            let url = crate::path_to_file_url(&repos_path);
5487            let mut client_ctx = crate::client::Context::new().unwrap();
5488
5489            // Checkout working copy
5490            let uri = crate::uri::Uri::new(&url).unwrap();
5491            client_ctx
5492                .checkout(uri, &wc_path, &Self::default_checkout_options())
5493                .unwrap();
5494
5495            Self {
5496                _repos_path: repos_path,
5497                wc_path,
5498                url,
5499                client_ctx,
5500                temp_dir,
5501            }
5502        }
5503
5504        /// Returns default checkout options for HEAD with full depth.
5505        fn default_checkout_options() -> crate::client::CheckoutOptions {
5506            crate::client::CheckoutOptions {
5507                peg_revision: crate::Revision::Head,
5508                revision: crate::Revision::Head,
5509                depth: crate::Depth::Infinity,
5510                ignore_externals: false,
5511                allow_unver_obstructions: false,
5512            }
5513        }
5514
5515        /// Creates and adds a file to the working copy.
5516        fn add_file(&mut self, name: &str, content: &str) -> PathBuf {
5517            let file_path = self.wc_path.join(name);
5518            std::fs::write(&file_path, content).unwrap();
5519            self.client_ctx
5520                .add(&file_path, &crate::client::AddOptions::new())
5521                .unwrap();
5522            file_path
5523        }
5524
5525        /// Creates and adds a directory to the working copy.
5526        fn add_dir(&mut self, name: &str) -> PathBuf {
5527            let dir_path = self.wc_path.join(name);
5528            std::fs::create_dir(&dir_path).unwrap();
5529            self.client_ctx
5530                .add(&dir_path, &crate::client::AddOptions::new())
5531                .unwrap();
5532            dir_path
5533        }
5534
5535        /// Gets the working copy path as a UTF-8 string slice.
5536        fn wc_path_str(&self) -> &str {
5537            self.wc_path
5538                .to_str()
5539                .expect("working copy path should be valid UTF-8")
5540        }
5541
5542        /// Gets the URL of the working copy using client.info().
5543        fn get_wc_url(&mut self) -> String {
5544            let wc_path = self
5545                .wc_path
5546                .to_str()
5547                .expect("path should be valid UTF-8")
5548                .to_string();
5549            let mut url = None;
5550            self.client_ctx
5551                .info(
5552                    &wc_path,
5553                    &crate::client::InfoOptions::default(),
5554                    &|_, info| {
5555                        url = Some(info.url().to_string());
5556                        Ok(())
5557                    },
5558                )
5559                .unwrap();
5560            url.expect("should have retrieved URL from info")
5561        }
5562
5563        /// Commits all changes in the working copy.
5564        fn commit(&mut self) {
5565            let wc_path_str = self.wc_path_str().to_string();
5566            let commit_opts = crate::client::CommitOptions::default();
5567            let revprops = std::collections::HashMap::new();
5568            self.client_ctx
5569                .commit(
5570                    &[wc_path_str.as_str()],
5571                    &commit_opts,
5572                    revprops,
5573                    None,
5574                    &mut |_info| Ok(()),
5575                )
5576                .unwrap();
5577        }
5578    }
5579
5580    /// Creates a repository and returns its path and URL.
5581    fn create_repo(base: &Path, name: &str) -> (PathBuf, String) {
5582        let repos_path = base.join(name);
5583        let _repos = crate::repos::Repos::create(&repos_path).unwrap();
5584        let url = crate::path_to_file_url(&repos_path);
5585        (repos_path, url)
5586    }
5587
5588    #[test]
5589    fn test_context_creation() {
5590        let context = Context::new().unwrap();
5591        assert!(!context.ptr.is_null());
5592    }
5593
5594    #[test]
5595    fn test_context_close() {
5596        let mut context = Context::new().unwrap();
5597        assert!(!context.ptr.is_null());
5598        context.close();
5599        assert!(context.ptr.is_null());
5600    }
5601
5602    #[test]
5603    fn test_context_close_idempotent() {
5604        let mut context = Context::new().unwrap();
5605        context.close();
5606        context.close(); // should not panic
5607    }
5608
5609    #[test]
5610    fn test_adm_dir_default() {
5611        // Default admin dir should be ".svn"
5612        let dir = get_adm_dir();
5613        assert_eq!(dir, ".svn");
5614    }
5615
5616    #[test]
5617    fn test_is_adm_dir() {
5618        // Test standard admin directory name
5619        assert!(is_adm_dir(".svn"));
5620
5621        // Test non-admin directory names
5622        assert!(!is_adm_dir("src"));
5623        assert!(!is_adm_dir("test"));
5624        assert!(!is_adm_dir(".git"));
5625    }
5626
5627    #[test]
5628    fn test_context_with_config() {
5629        // Create context with empty config
5630        let config = std::ptr::null_mut();
5631        Context::new_with_config(config).unwrap();
5632    }
5633
5634    #[test]
5635    fn test_check_wc() {
5636        let dir = tempdir().unwrap();
5637        let wc_path = dir.path();
5638
5639        // Non-working-copy directory should return None
5640        let wc_format = check_wc(wc_path).unwrap();
5641        assert_eq!(wc_format, None);
5642    }
5643
5644    #[test]
5645    fn test_ensure_adm() {
5646        let dir = tempdir().unwrap();
5647        let wc_path = dir.path();
5648
5649        // Try to ensure admin area
5650        let result = ensure_adm(
5651            wc_path,
5652            "",                  // uuid
5653            "file:///test/repo", // url
5654            "file:///test/repo", // repos
5655            0,                   // revision
5656        );
5657
5658        result.unwrap();
5659    }
5660
5661    // Note: Context cannot be Send because it contains raw pointers to C structures
5662
5663    #[test]
5664    fn test_text_modified() {
5665        let dir = tempdir().unwrap();
5666        let file_path = dir.path().join("test.txt");
5667        std::fs::write(&file_path, "test content").unwrap();
5668
5669        // This will fail without a working copy, but shouldn't panic
5670        let result = text_modified(&file_path, false);
5671        assert!(result.is_err()); // Expected to fail without WC
5672    }
5673
5674    #[test]
5675    fn test_props_modified() {
5676        let dir = tempdir().unwrap();
5677        let file_path = dir.path().join("test.txt");
5678        std::fs::write(&file_path, "test content").unwrap();
5679
5680        // This will fail without a working copy, but shouldn't panic
5681        let result = props_modified(&file_path);
5682        assert!(result.is_err()); // Expected to fail without WC
5683    }
5684
5685    #[test]
5686    fn test_status_enum() {
5687        // Test StatusKind enum conversions
5688        assert_eq!(
5689            StatusKind::Normal as u32,
5690            subversion_sys::svn_wc_status_kind_svn_wc_status_normal as u32
5691        );
5692        assert_eq!(
5693            StatusKind::Added as u32,
5694            subversion_sys::svn_wc_status_kind_svn_wc_status_added as u32
5695        );
5696        assert_eq!(
5697            StatusKind::Deleted as u32,
5698            subversion_sys::svn_wc_status_kind_svn_wc_status_deleted as u32
5699        );
5700
5701        // Test From conversion
5702        let status = StatusKind::from(subversion_sys::svn_wc_status_kind_svn_wc_status_modified);
5703        assert_eq!(status, StatusKind::Modified);
5704    }
5705
5706    #[test]
5707    fn test_schedule_enum() {
5708        // Test Schedule enum conversions
5709        assert_eq!(
5710            Schedule::Normal as u32,
5711            subversion_sys::svn_wc_schedule_t_svn_wc_schedule_normal as u32
5712        );
5713        assert_eq!(
5714            Schedule::Add as u32,
5715            subversion_sys::svn_wc_schedule_t_svn_wc_schedule_add as u32
5716        );
5717
5718        // Test From conversion
5719        let schedule = Schedule::from(subversion_sys::svn_wc_schedule_t_svn_wc_schedule_delete);
5720        assert_eq!(schedule, Schedule::Delete);
5721    }
5722
5723    #[test]
5724    fn test_is_normal_prop() {
5725        // "Normal" properties are versioned properties that users can set
5726        // SVN properties like svn:keywords ARE normal properties
5727        assert!(is_normal_prop("svn:keywords"));
5728        assert!(is_normal_prop("svn:eol-style"));
5729        assert!(is_normal_prop("svn:mime-type"));
5730
5731        // Entry and WC properties are NOT normal
5732        assert!(!is_normal_prop("svn:entry:committed-rev"));
5733        assert!(!is_normal_prop("svn:wc:ra_dav:version-url"));
5734    }
5735
5736    #[test]
5737    fn test_is_entry_prop() {
5738        // These should be entry properties
5739        assert!(is_entry_prop("svn:entry:committed-rev"));
5740        assert!(is_entry_prop("svn:entry:uuid"));
5741
5742        // These should not be entry properties
5743        assert!(!is_entry_prop("svn:keywords"));
5744        assert!(!is_entry_prop("user:custom"));
5745    }
5746
5747    #[test]
5748    fn test_is_wc_prop() {
5749        // These should be WC properties
5750        assert!(is_wc_prop("svn:wc:ra_dav:version-url"));
5751
5752        // These should not be WC properties
5753        assert!(!is_wc_prop("svn:keywords"));
5754        assert!(!is_wc_prop("user:custom"));
5755    }
5756
5757    #[test]
5758    fn test_conflict_choice_enum() {
5759        // Test that ConflictChoice enum values map correctly
5760        assert_eq!(
5761            ConflictChoice::Postpone as i32,
5762            subversion_sys::svn_wc_conflict_choice_t_svn_wc_conflict_choose_postpone
5763        );
5764        assert_eq!(
5765            ConflictChoice::Base as i32,
5766            subversion_sys::svn_wc_conflict_choice_t_svn_wc_conflict_choose_base
5767        );
5768        assert_eq!(
5769            ConflictChoice::Theirs as i32,
5770            subversion_sys::svn_wc_conflict_choice_t_svn_wc_conflict_choose_theirs_full
5771        );
5772        assert_eq!(
5773            ConflictChoice::Mine as i32,
5774            subversion_sys::svn_wc_conflict_choice_t_svn_wc_conflict_choose_mine_full
5775        );
5776    }
5777
5778    #[test]
5779    fn test_crop_tree_basic() {
5780        // Test that crop_tree function compiles and can be called
5781        // Creating a real working copy for testing would require full SVN setup
5782        let mut ctx = Context::new().unwrap();
5783        let tempdir = tempdir().unwrap();
5784
5785        // This will fail without a working copy, but tests the API
5786        let result = ctx.crop_tree(tempdir.path(), crate::Depth::Files, None);
5787
5788        // Expected to fail without valid working copy
5789        assert!(result.is_err());
5790    }
5791
5792    #[test]
5793    fn test_resolved_conflict_basic() {
5794        // Test that resolved_conflict function compiles and can be called
5795        let mut ctx = Context::new().unwrap();
5796        let tempdir = tempdir().unwrap();
5797
5798        // This will fail without a working copy with conflicts, but tests the API
5799        let result = ctx.resolved_conflict(
5800            tempdir.path(),
5801            crate::Depth::Infinity,
5802            true,  // resolve_text
5803            None,  // resolve_property
5804            false, // resolve_tree
5805            ConflictChoice::Mine,
5806            None,
5807        );
5808
5809        // Expected to fail without valid working copy
5810        assert!(result.is_err());
5811    }
5812
5813    #[test]
5814    fn test_conflict_choice_conversion() {
5815        // Test that ConflictChoice enum converts properly to SVN types
5816        let choice = ConflictChoice::Mine;
5817        let svn_choice: subversion_sys::svn_wc_conflict_choice_t = choice.into();
5818        assert_eq!(
5819            svn_choice,
5820            subversion_sys::svn_wc_conflict_choice_t_svn_wc_conflict_choose_mine_full
5821        );
5822
5823        let choice = ConflictChoice::Theirs;
5824        let svn_choice: subversion_sys::svn_wc_conflict_choice_t = choice.into();
5825        assert_eq!(
5826            svn_choice,
5827            subversion_sys::svn_wc_conflict_choice_t_svn_wc_conflict_choose_theirs_full
5828        );
5829    }
5830
5831    #[test]
5832    fn test_match_ignore_list() {
5833        // Test exact matches
5834        assert!(match_ignore_list("foo", &["foo", "bar"]).unwrap());
5835        assert!(match_ignore_list("bar", &["foo", "bar"]).unwrap());
5836        assert!(!match_ignore_list("baz", &["foo", "bar"]).unwrap());
5837
5838        // Test wildcard patterns
5839        assert!(match_ignore_list("foo", &["f*"]).unwrap());
5840        assert!(match_ignore_list("foobar", &["f*"]).unwrap());
5841        assert!(!match_ignore_list("bar", &["f*"]).unwrap());
5842
5843        // Test file extension patterns
5844        assert!(match_ignore_list("test.txt", &["*.txt"]).unwrap());
5845        assert!(match_ignore_list("file.txt", &["*.txt", "*.log"]).unwrap());
5846        assert!(!match_ignore_list("test.rs", &["*.txt"]).unwrap());
5847
5848        // Test empty patterns
5849        assert!(!match_ignore_list("foo", &[]).unwrap());
5850    }
5851
5852    #[test]
5853    fn test_add_from_disk() {
5854        // This test requires a working copy, so we just test that the API compiles
5855        // and returns an error when used on a non-WC directory
5856        let temp_dir = tempfile::tempdir().unwrap();
5857        let mut ctx = Context::new().unwrap();
5858
5859        let file_path = temp_dir.path().join("test.txt");
5860        std::fs::write(&file_path, "test content").unwrap();
5861
5862        // Should fail without a working copy
5863        let result = ctx.add_from_disk(&file_path, None, false, None);
5864        assert!(result.is_err());
5865    }
5866
5867    #[test]
5868    fn test_add_repos_file() {
5869        // Test that add_repos_file() reaches svn_wc_add_repos_file4 and fails
5870        // gracefully when the path is not inside a working copy.
5871        let temp_dir = tempfile::tempdir().unwrap();
5872        let mut ctx = Context::new().unwrap();
5873
5874        let file_path = temp_dir.path().join("newfile.txt");
5875
5876        let content = b"file content\n";
5877        let mut base_stream = crate::io::Stream::from(&content[..]);
5878        let base_props: std::collections::HashMap<String, Vec<u8>> =
5879            std::collections::HashMap::new();
5880
5881        let result = ctx.add_repos_file(
5882            &file_path,
5883            &mut base_stream,
5884            None,
5885            &base_props,
5886            None,
5887            None,
5888            crate::Revnum(-1),
5889            None,
5890        );
5891        // Should fail because the path is not inside a versioned working copy.
5892        assert!(result.is_err());
5893    }
5894
5895    #[test]
5896    fn test_move_path() {
5897        // Test that the API compiles and fails gracefully without a WC
5898        let temp_dir = tempfile::tempdir().unwrap();
5899        let mut ctx = Context::new().unwrap();
5900
5901        let src = temp_dir.path().join("src.txt");
5902        let dst = temp_dir.path().join("dst.txt");
5903        std::fs::write(&src, "content").unwrap();
5904
5905        // Should fail without a working copy
5906        let result = ctx.move_path(&src, &dst, false, false, None, None);
5907        assert!(result.is_err());
5908    }
5909
5910    #[test]
5911    fn test_delete() {
5912        // Test that the API compiles and fails gracefully without a WC
5913        let temp_dir = tempfile::tempdir().unwrap();
5914        let mut ctx = Context::new().unwrap();
5915
5916        let file_path = temp_dir.path().join("test.txt");
5917        std::fs::write(&file_path, "test content").unwrap();
5918
5919        // Should fail without a working copy
5920        let result = ctx.delete(&file_path, false, false, None, None);
5921        assert!(result.is_err());
5922    }
5923
5924    #[test]
5925    fn test_get_switch_editor() {
5926        // Test that the API compiles and can be called
5927        let temp_dir = tempfile::tempdir().unwrap();
5928        let mut ctx = Context::new().unwrap();
5929
5930        // This will fail without a working copy, but tests the API
5931        let options = SwitchEditorOptions::new();
5932        let result = ctx.get_switch_editor(
5933            temp_dir.path().to_str().unwrap(),
5934            "",
5935            "http://example.com/repo/branches/test",
5936            options,
5937        );
5938
5939        // Expected to fail without a valid working copy
5940        assert!(result.is_err());
5941    }
5942
5943    #[test]
5944    fn test_get_switch_editor_api() {
5945        // Test that the switch editor API compiles and can be called
5946        let temp_dir = tempfile::tempdir().unwrap();
5947        let mut ctx = Context::new().unwrap();
5948
5949        // This will fail without a working copy, but tests the API
5950        let options = SwitchEditorOptions::new();
5951        let result = ctx.get_switch_editor(
5952            temp_dir.path().to_str().unwrap(),
5953            "",
5954            "http://example.com/svn/trunk",
5955            options,
5956        );
5957
5958        // Expected to fail without a valid working copy
5959        assert!(result.is_err());
5960    }
5961
5962    #[test]
5963    fn test_get_switch_editor_with_target() {
5964        // Test switch editor with target basename
5965        let temp_dir = tempfile::tempdir().unwrap();
5966        let mut ctx = Context::new().unwrap();
5967
5968        let options = SwitchEditorOptions {
5969            use_commit_times: true,
5970            depth: crate::Depth::Files,
5971            depth_is_sticky: true,
5972            allow_unver_obstructions: true,
5973            ..Default::default()
5974        };
5975        let result = ctx.get_switch_editor(
5976            temp_dir.path().to_str().unwrap(),
5977            "subdir", // target basename
5978            "http://example.com/svn/branches/test",
5979            options,
5980        );
5981
5982        assert!(result.is_err());
5983    }
5984
5985    #[test]
5986    fn test_get_diff_editor() {
5987        // Test that the API compiles and can be called with a real WC.
5988        let temp_dir = tempfile::tempdir().unwrap();
5989        let wc_path = temp_dir.path().join("wc");
5990        std::fs::create_dir(&wc_path).unwrap();
5991
5992        // Create a proper working copy
5993        ensure_adm(
5994            &wc_path,
5995            "test-uuid",
5996            "file:///tmp/test-repo",
5997            "file:///tmp/test-repo",
5998            0,
5999        )
6000        .unwrap();
6001
6002        let mut ctx = Context::new().unwrap();
6003        let mut callbacks = RecordingDiffCallbacks::new();
6004
6005        let result = ctx.get_diff_editor(
6006            wc_path.to_str().unwrap(),
6007            wc_path.to_str().unwrap(),
6008            &mut callbacks,
6009            false, // use_text_base
6010            crate::Depth::Infinity,
6011            false, // ignore_ancestry
6012            false, // show_copies_as_adds
6013            false, // use_git_diff_format
6014        );
6015
6016        result.unwrap();
6017    }
6018
6019    #[test]
6020    fn test_get_diff_editor_with_options() {
6021        // Test diff editor with various options using a real WC.
6022        let temp_dir = tempfile::tempdir().unwrap();
6023        let wc_path = temp_dir.path().join("wc");
6024        std::fs::create_dir(&wc_path).unwrap();
6025
6026        // Create a proper working copy
6027        ensure_adm(
6028            &wc_path,
6029            "test-uuid",
6030            "file:///tmp/test-repo",
6031            "file:///tmp/test-repo",
6032            0,
6033        )
6034        .unwrap();
6035
6036        let mut ctx = Context::new().unwrap();
6037        let mut callbacks = RecordingDiffCallbacks::new();
6038
6039        let result = ctx.get_diff_editor(
6040            wc_path.to_str().unwrap(),
6041            wc_path.to_str().unwrap(),
6042            &mut callbacks,
6043            true, // use_text_base
6044            crate::Depth::Empty,
6045            true, // ignore_ancestry
6046            true, // show_copies_as_adds
6047            true, // use_git_diff_format
6048        );
6049
6050        result.unwrap();
6051    }
6052
6053    #[test]
6054    fn test_update_editor_trait() {
6055        // Test that UpdateEditor implements the Editor trait
6056        use crate::delta::Editor;
6057
6058        // This just verifies the trait implementation compiles
6059        fn check_editor_impl<T: Editor>() {}
6060
6061        // Verify UpdateEditor implements Editor trait
6062        check_editor_impl::<UpdateEditor<'_>>();
6063    }
6064
6065    #[test]
6066    fn test_committed_queue() {
6067        // Test CommittedQueue creation
6068        let queue = CommittedQueue::new();
6069        assert!(!queue.ptr.is_null());
6070
6071        // Test queue_committed and process_committed_queue APIs
6072        let temp_dir = tempfile::tempdir().unwrap();
6073        let mut ctx = Context::new().unwrap();
6074        let mut queue = CommittedQueue::new();
6075
6076        let file_path = temp_dir.path().join("test.txt");
6077        std::fs::write(&file_path, "test content").unwrap();
6078
6079        // Should fail without a working copy
6080        let result = ctx.queue_committed(
6081            &file_path, false, true, &mut queue, None, false, false, None,
6082        );
6083        assert!(result.is_err());
6084    }
6085
6086    #[test]
6087    fn test_wc_prop_operations() {
6088        let mut fixture = SvnTestFixture::new();
6089        let file_path = fixture.add_file("test.txt", "test content");
6090
6091        // Use client API to set a property (it handles locking)
6092        fixture
6093            .client_ctx
6094            .propset(
6095                "test:property",
6096                Some(b"test value"),
6097                file_path.to_str().expect("file path should be valid UTF-8"),
6098                &crate::client::PropSetOptions::default(),
6099            )
6100            .unwrap();
6101
6102        // Now test the wc property functions for reading
6103        let mut wc_ctx = Context::new().unwrap();
6104
6105        // Test prop_get - retrieve the property we set via client
6106        let value = wc_ctx.prop_get(&file_path, "test:property").unwrap();
6107        assert_eq!(value, Some(b"test value".to_vec()));
6108
6109        // Test prop_get for non-existent property
6110        let missing = wc_ctx.prop_get(&file_path, "test:missing").unwrap();
6111        assert_eq!(missing, None);
6112
6113        // Test prop_list - list all properties
6114        let props = wc_ctx.prop_list(&file_path).unwrap();
6115        assert!(props.contains_key("test:property"));
6116        assert_eq!(props.get("test:property").unwrap(), b"test value");
6117    }
6118
6119    #[test]
6120    fn test_get_prop_diffs() {
6121        let mut fixture = SvnTestFixture::new();
6122        let file_path = fixture.add_file("test.txt", "test content");
6123
6124        // Set a property without committing
6125        fixture
6126            .client_ctx
6127            .propset(
6128                "test:prop1",
6129                Some(b"value1"),
6130                file_path.to_str().expect("file path should be valid UTF-8"),
6131                &crate::client::PropSetOptions::default(),
6132            )
6133            .unwrap();
6134
6135        // Test get_prop_diffs - should show the new property as a change
6136        let mut wc_ctx = Context::new().unwrap();
6137        let (changes, original) = wc_ctx.get_prop_diffs(&file_path).unwrap();
6138
6139        // Should have 1 property change
6140        assert_eq!(changes.len(), 1);
6141        assert_eq!(changes[0].name, "test:prop1");
6142        assert_eq!(changes[0].value, Some(b"value1".to_vec()));
6143
6144        // Original props should not contain our property (it's only in working copy)
6145        if let Some(orig) = original {
6146            assert!(!orig.contains_key("test:prop1"));
6147        }
6148    }
6149
6150    #[test]
6151    fn test_read_kind() {
6152        let mut fixture = SvnTestFixture::new();
6153        let file_path = fixture.add_file("test.txt", "test content");
6154        let dir_path = fixture.add_dir("subdir");
6155
6156        // Test read_kind
6157        let mut wc_ctx = Context::new().unwrap();
6158
6159        // Check the working copy root is a directory
6160        let kind = wc_ctx.read_kind(&fixture.wc_path, false, false).unwrap();
6161        assert_eq!(kind, crate::NodeKind::Dir);
6162
6163        // Check the file is recognized as a file
6164        let kind = wc_ctx.read_kind(&file_path, false, false).unwrap();
6165        assert_eq!(kind, crate::NodeKind::File);
6166
6167        // Check the directory
6168        let kind = wc_ctx.read_kind(&dir_path, false, false).unwrap();
6169        assert_eq!(kind, crate::NodeKind::Dir);
6170
6171        // Check a non-existent path
6172        let nonexistent = fixture.wc_path.join("nonexistent");
6173        let kind = wc_ctx.read_kind(&nonexistent, false, false).unwrap();
6174        assert_eq!(kind, crate::NodeKind::None);
6175    }
6176
6177    #[test]
6178    fn test_is_wc_root() {
6179        let mut fixture = SvnTestFixture::new();
6180        let subdir = fixture.add_dir("subdir");
6181
6182        // Test is_wc_root
6183        let mut wc_ctx = Context::new().unwrap();
6184
6185        // The working copy root should return true
6186        let is_root = wc_ctx.is_wc_root(&fixture.wc_path).unwrap();
6187        assert!(is_root, "Working copy root should be detected as WC root");
6188
6189        // A subdirectory should return false
6190        let is_root = wc_ctx.is_wc_root(&subdir).unwrap();
6191        assert!(!is_root, "Subdirectory should not be a WC root");
6192    }
6193
6194    #[test]
6195    fn test_get_pristine_contents() {
6196        use std::io::Read;
6197
6198        let mut fixture = SvnTestFixture::new();
6199
6200        // Create and commit a file with original content
6201        let original_content = "original content";
6202        let file_path = fixture.add_file("test.txt", original_content);
6203        fixture.commit();
6204
6205        // Modify the file
6206        let modified_content = "modified content";
6207        std::fs::write(&file_path, modified_content).unwrap();
6208
6209        // Get pristine contents
6210        let mut wc_ctx = Context::new().unwrap();
6211        let pristine_stream = wc_ctx
6212            .get_pristine_contents(file_path.to_str().expect("file path should be valid UTF-8"))
6213            .unwrap();
6214
6215        // Should have pristine contents
6216        assert!(
6217            pristine_stream.is_some(),
6218            "Should have pristine contents for committed file"
6219        );
6220
6221        // Read and verify pristine contents match original
6222        let mut pristine_stream = pristine_stream.unwrap();
6223        let mut pristine_content = String::new();
6224        pristine_stream
6225            .read_to_string(&mut pristine_content)
6226            .unwrap();
6227
6228        assert_eq!(
6229            pristine_content, original_content,
6230            "Pristine content should match original"
6231        );
6232
6233        // Test with a newly added file (no pristine)
6234        let new_file = fixture.add_file("new.txt", "new file content");
6235
6236        let pristine = wc_ctx
6237            .get_pristine_contents(
6238                new_file
6239                    .to_str()
6240                    .expect("new file path should be valid UTF-8"),
6241            )
6242            .unwrap();
6243        assert!(
6244            pristine.is_none(),
6245            "Newly added file should have no pristine contents"
6246        );
6247    }
6248
6249    #[test]
6250    fn test_exclude() {
6251        use std::cell::{Cell, RefCell};
6252        use tempfile::TempDir;
6253
6254        // Create a repository and working copy
6255        let temp_dir = TempDir::new().unwrap();
6256        let repos_path = temp_dir.path().join("repos");
6257        let wc_path = temp_dir.path().join("wc");
6258
6259        // Create a repository
6260        let _repos = crate::repos::Repos::create(&repos_path).unwrap();
6261
6262        // Create a working copy using client API
6263        let url_str = crate::path_to_file_url(&repos_path);
6264        let url = crate::uri::Uri::new(&url_str).unwrap();
6265        let mut client_ctx = crate::client::Context::new().unwrap();
6266
6267        let checkout_opts = crate::client::CheckoutOptions {
6268            revision: crate::Revision::Head,
6269            peg_revision: crate::Revision::Head,
6270            depth: crate::Depth::Infinity,
6271            ignore_externals: false,
6272            allow_unver_obstructions: false,
6273        };
6274        client_ctx.checkout(url, &wc_path, &checkout_opts).unwrap();
6275
6276        // Create a subdirectory with a file and commit it
6277        let subdir = wc_path.join("subdir");
6278        std::fs::create_dir(&subdir).unwrap();
6279
6280        let file_in_subdir = subdir.join("file.txt");
6281        std::fs::write(&file_in_subdir, "content").unwrap();
6282
6283        client_ctx
6284            .add(&subdir, &crate::client::AddOptions::new())
6285            .unwrap();
6286
6287        let commit_opts = crate::client::CommitOptions::default();
6288        let revprops = std::collections::HashMap::new();
6289        client_ctx
6290            .commit(
6291                &[wc_path.to_str().unwrap()],
6292                &commit_opts,
6293                revprops,
6294                None,
6295                &mut |_info| Ok(()),
6296            )
6297            .unwrap();
6298
6299        // Verify the subdirectory exists
6300        assert!(subdir.exists(), "Subdirectory should exist before exclude");
6301        assert!(file_in_subdir.exists(), "File should exist before exclude");
6302
6303        // Test exclude with notification callback
6304        let mut wc_ctx = Context::new().unwrap();
6305        let notifications = RefCell::new(Vec::new());
6306
6307        let result = wc_ctx.exclude(
6308            &subdir,
6309            None,
6310            Some(&|notify: &Notify| {
6311                // Collect notifications to verify exclude is working
6312                notifications
6313                    .borrow_mut()
6314                    .push(format!("{:?}", notify.action()));
6315            }),
6316        );
6317
6318        // Should succeed
6319        assert!(result.is_ok(), "Exclude should succeed: {:?}", result);
6320
6321        // Verify the directory is now excluded (removed from disk)
6322        assert!(
6323            !subdir.exists(),
6324            "Subdirectory should not exist after exclude"
6325        );
6326
6327        // Verify we got notification callbacks
6328        assert!(
6329            !notifications.borrow().is_empty(),
6330            "Should have received notifications"
6331        );
6332
6333        // Verify the directory can be checked for kind (should return None/excluded)
6334        let kind = wc_ctx.read_kind(&subdir, false, false).unwrap();
6335        assert_eq!(
6336            kind,
6337            crate::NodeKind::None,
6338            "Excluded directory should show as None"
6339        );
6340
6341        // Test exclude with cancel callback
6342        let subdir2 = wc_path.join("subdir2");
6343        std::fs::create_dir(&subdir2).unwrap();
6344        client_ctx
6345            .add(&subdir2, &crate::client::AddOptions::new())
6346            .unwrap();
6347
6348        let commit_opts = crate::client::CommitOptions::default();
6349        let revprops = std::collections::HashMap::new();
6350        client_ctx
6351            .commit(
6352                &[wc_path.to_str().unwrap()],
6353                &commit_opts,
6354                revprops,
6355                None,
6356                &mut |_info| Ok(()),
6357            )
6358            .unwrap();
6359
6360        // Test that cancel callback is called (but don't actually cancel)
6361        let cancel_called = Cell::new(false);
6362        let result = wc_ctx.exclude(
6363            &subdir2,
6364            Some(&|| {
6365                cancel_called.set(true);
6366                Ok(()) // Don't actually cancel
6367            }),
6368            None,
6369        );
6370
6371        assert!(
6372            result.is_ok(),
6373            "Exclude with cancel callback should succeed"
6374        );
6375        assert!(
6376            cancel_called.get(),
6377            "Cancel callback should have been called"
6378        );
6379        assert!(!subdir2.exists(), "Second subdirectory should be excluded");
6380    }
6381
6382    #[test]
6383    fn test_get_pristine_props() {
6384        use tempfile::TempDir;
6385
6386        // Create a repository and working copy
6387        let temp_dir = TempDir::new().unwrap();
6388        let repos_path = temp_dir.path().join("repos");
6389        let wc_path = temp_dir.path().join("wc");
6390
6391        // Create a repository
6392        let _repos = crate::repos::Repos::create(&repos_path).unwrap();
6393
6394        // Create a working copy using client API
6395        let url_str = crate::path_to_file_url(&repos_path);
6396        let url = crate::uri::Uri::new(&url_str).unwrap();
6397        let mut client_ctx = crate::client::Context::new().unwrap();
6398
6399        let checkout_opts = crate::client::CheckoutOptions {
6400            revision: crate::Revision::Head,
6401            peg_revision: crate::Revision::Head,
6402            depth: crate::Depth::Infinity,
6403            ignore_externals: false,
6404            allow_unver_obstructions: false,
6405        };
6406        client_ctx.checkout(url, &wc_path, &checkout_opts).unwrap();
6407
6408        // Create a file with properties
6409        let file_path = wc_path.join("test.txt");
6410        std::fs::write(&file_path, "original content").unwrap();
6411
6412        client_ctx
6413            .add(&file_path, &crate::client::AddOptions::new())
6414            .unwrap();
6415
6416        // Set some properties
6417        let propset_opts = crate::client::PropSetOptions::default();
6418        client_ctx
6419            .propset(
6420                "svn:eol-style",
6421                Some(b"native"),
6422                file_path.to_str().unwrap(),
6423                &propset_opts,
6424            )
6425            .unwrap();
6426        client_ctx
6427            .propset(
6428                "custom:prop",
6429                Some(b"custom value"),
6430                file_path.to_str().unwrap(),
6431                &propset_opts,
6432            )
6433            .unwrap();
6434
6435        // Commit the file with properties
6436        let commit_opts = crate::client::CommitOptions::default();
6437        let revprops = std::collections::HashMap::new();
6438        client_ctx
6439            .commit(
6440                &[wc_path.to_str().unwrap()],
6441                &commit_opts,
6442                revprops,
6443                None,
6444                &mut |_info| Ok(()),
6445            )
6446            .unwrap();
6447
6448        // Test 1: Get pristine props (should match what we set)
6449        let mut wc_ctx = Context::new().unwrap();
6450        let pristine_props = wc_ctx
6451            .get_pristine_props(file_path.to_str().unwrap())
6452            .unwrap();
6453
6454        assert!(
6455            pristine_props.is_some(),
6456            "Committed file should have pristine props"
6457        );
6458        let props = pristine_props.unwrap();
6459        assert!(
6460            props.contains_key("svn:eol-style"),
6461            "Should have svn:eol-style property"
6462        );
6463        assert_eq!(
6464            props.get("svn:eol-style").unwrap(),
6465            b"native",
6466            "svn:eol-style should be 'native'"
6467        );
6468        assert!(
6469            props.contains_key("custom:prop"),
6470            "Should have custom:prop property"
6471        );
6472        assert_eq!(
6473            props.get("custom:prop").unwrap(),
6474            b"custom value",
6475            "custom:prop should be 'custom value'"
6476        );
6477
6478        // Test 2: Modify a property locally (pristine should remain unchanged)
6479        client_ctx
6480            .propset(
6481                "svn:eol-style",
6482                Some(b"LF"),
6483                file_path.to_str().unwrap(),
6484                &propset_opts,
6485            )
6486            .unwrap();
6487
6488        // Pristine props should still be the original values
6489        let pristine_props = wc_ctx
6490            .get_pristine_props(file_path.to_str().unwrap())
6491            .unwrap()
6492            .unwrap();
6493        assert_eq!(
6494            pristine_props.get("svn:eol-style").unwrap(),
6495            b"native",
6496            "Pristine svn:eol-style should still be 'native'"
6497        );
6498
6499        // Test 3: Delete a property locally (pristine should still have it)
6500        client_ctx
6501            .propset(
6502                "custom:prop",
6503                None,
6504                file_path.to_str().unwrap(),
6505                &propset_opts,
6506            )
6507            .unwrap();
6508
6509        let pristine_props = wc_ctx
6510            .get_pristine_props(file_path.to_str().unwrap())
6511            .unwrap()
6512            .unwrap();
6513        assert!(
6514            pristine_props.contains_key("custom:prop"),
6515            "Pristine props should still contain deleted property"
6516        );
6517
6518        // Test 4: Newly added file should have None pristine props (or empty hash)
6519        let new_file = wc_path.join("new.txt");
6520        std::fs::write(&new_file, "new content").unwrap();
6521        client_ctx
6522            .add(&new_file, &crate::client::AddOptions::new())
6523            .unwrap();
6524
6525        let pristine = wc_ctx
6526            .get_pristine_props(new_file.to_str().unwrap())
6527            .unwrap();
6528        // Newly added files return None according to API docs, but implementation may vary
6529        if let Some(props) = pristine {
6530            // If not None, should at least be empty (no committed properties yet)
6531            assert!(
6532                props.is_empty() || props.len() == 0,
6533                "Newly added file pristine props should be empty if not None"
6534            );
6535        }
6536
6537        // Test 5: Non-existent file should error
6538        let nonexistent = wc_path.join("nonexistent.txt");
6539        let result = wc_ctx.get_pristine_props(nonexistent.to_str().unwrap());
6540        assert!(result.is_err(), "Non-existent file should return an error");
6541    }
6542
6543    #[test]
6544    fn test_conflicted() {
6545        use tempfile::TempDir;
6546
6547        // Create a repository and two working copies
6548        let temp_dir = TempDir::new().unwrap();
6549        let repos_path = temp_dir.path().join("repos");
6550        let wc1_path = temp_dir.path().join("wc1");
6551        let wc2_path = temp_dir.path().join("wc2");
6552
6553        // Create a repository
6554        let _repos = crate::repos::Repos::create(&repos_path).unwrap();
6555
6556        let url_str = crate::path_to_file_url(&repos_path);
6557        let url = crate::uri::Uri::new(&url_str).unwrap();
6558
6559        // Create first working copy
6560        let mut client_ctx1 = crate::client::Context::new().unwrap();
6561        let checkout_opts = crate::client::CheckoutOptions {
6562            revision: crate::Revision::Head,
6563            peg_revision: crate::Revision::Head,
6564            depth: crate::Depth::Infinity,
6565            ignore_externals: false,
6566            allow_unver_obstructions: false,
6567        };
6568        client_ctx1
6569            .checkout(url.clone(), &wc1_path, &checkout_opts)
6570            .unwrap();
6571
6572        // Create a file and commit it (r1)
6573        let file_path1 = wc1_path.join("test.txt");
6574        std::fs::write(&file_path1, "line1\nline2\nline3\n").unwrap();
6575        client_ctx1
6576            .add(&file_path1, &crate::client::AddOptions::new())
6577            .unwrap();
6578
6579        let commit_opts = crate::client::CommitOptions::default();
6580        let revprops = std::collections::HashMap::new();
6581        client_ctx1
6582            .commit(
6583                &[wc1_path.to_str().unwrap()],
6584                &commit_opts,
6585                revprops.clone(),
6586                None,
6587                &mut |_info| Ok(()),
6588            )
6589            .unwrap();
6590
6591        // Create second working copy from same revision
6592        let mut client_ctx2 = crate::client::Context::new().unwrap();
6593        client_ctx2
6594            .checkout(url.clone(), &wc2_path, &checkout_opts)
6595            .unwrap();
6596
6597        // In WC1: modify the file and commit (r2)
6598        std::fs::write(&file_path1, "line1 modified in wc1\nline2\nline3\n").unwrap();
6599        client_ctx1
6600            .commit(
6601                &[wc1_path.to_str().unwrap()],
6602                &commit_opts,
6603                revprops.clone(),
6604                None,
6605                &mut |_info| Ok(()),
6606            )
6607            .unwrap();
6608
6609        // In WC2: modify the same line differently
6610        let file_path2 = wc2_path.join("test.txt");
6611        std::fs::write(&file_path2, "line1 modified in wc2\nline2\nline3\n").unwrap();
6612
6613        // Ensure SVN will detect the modification by waiting for timestamp rollover
6614        ensure_timestamp_rollover();
6615
6616        // Try to update WC2 - this should create a text conflict
6617        let update_opts = crate::client::UpdateOptions::default();
6618        let _result = client_ctx2.update(
6619            &[wc2_path.to_str().unwrap()],
6620            crate::Revision::Head,
6621            &update_opts,
6622        );
6623        // Update might succeed but leave conflicts
6624
6625        // Test 1: Check for text conflict
6626        let mut wc_ctx = Context::new().unwrap();
6627        let (text_conflicted, prop_conflicted, tree_conflicted) =
6628            wc_ctx.conflicted(file_path2.to_str().unwrap()).unwrap();
6629
6630        assert!(
6631            text_conflicted,
6632            "File should have text conflict after conflicting update"
6633        );
6634        assert!(!prop_conflicted, "File should not have property conflict");
6635        assert!(!tree_conflicted, "File should not have tree conflict");
6636
6637        // Test 2: Create a property conflict
6638        let propfile_path1 = wc1_path.join("proptest.txt");
6639        std::fs::write(&propfile_path1, "content").unwrap();
6640        client_ctx1
6641            .add(&propfile_path1, &crate::client::AddOptions::new())
6642            .unwrap();
6643
6644        let propset_opts = crate::client::PropSetOptions::default();
6645        client_ctx1
6646            .propset(
6647                "custom:prop",
6648                Some(b"value1"),
6649                propfile_path1.to_str().unwrap(),
6650                &propset_opts,
6651            )
6652            .unwrap();
6653
6654        client_ctx1
6655            .commit(
6656                &[wc1_path.to_str().unwrap()],
6657                &commit_opts,
6658                revprops.clone(),
6659                None,
6660                &mut |_info| Ok(()),
6661            )
6662            .unwrap();
6663
6664        // In WC2, update to get the file, then set conflicting property
6665        let _result = client_ctx2.update(
6666            &[wc2_path.to_str().unwrap()],
6667            crate::Revision::Head,
6668            &update_opts,
6669        );
6670
6671        let propfile_path2 = wc2_path.join("proptest.txt");
6672        client_ctx2
6673            .propset(
6674                "custom:prop",
6675                Some(b"value2"),
6676                propfile_path2.to_str().unwrap(),
6677                &propset_opts,
6678            )
6679            .unwrap();
6680
6681        // Commit in WC1 with different value
6682        client_ctx1
6683            .propset(
6684                "custom:prop",
6685                Some(b"value1_modified"),
6686                propfile_path1.to_str().unwrap(),
6687                &propset_opts,
6688            )
6689            .unwrap();
6690        client_ctx1
6691            .commit(
6692                &[wc1_path.to_str().unwrap()],
6693                &commit_opts,
6694                revprops.clone(),
6695                None,
6696                &mut |_info| Ok(()),
6697            )
6698            .unwrap();
6699
6700        // Update WC2 - should create property conflict
6701        let _result = client_ctx2.update(
6702            &[wc2_path.to_str().unwrap()],
6703            crate::Revision::Head,
6704            &update_opts,
6705        );
6706
6707        let (_text_conflicted, prop_conflicted, _tree_conflicted) =
6708            wc_ctx.conflicted(propfile_path2.to_str().unwrap()).unwrap();
6709
6710        // We might have prop conflict depending on SVN version behavior
6711        if prop_conflicted {
6712            assert!(
6713                prop_conflicted,
6714                "File should have property conflict after conflicting property update"
6715            );
6716        }
6717
6718        // Test 3: File without conflicts should return all false
6719        let clean_file = wc1_path.join("clean.txt");
6720        std::fs::write(&clean_file, "no conflicts here").unwrap();
6721        client_ctx1
6722            .add(&clean_file, &crate::client::AddOptions::new())
6723            .unwrap();
6724
6725        let (text_conflicted, prop_conflicted, tree_conflicted) =
6726            wc_ctx.conflicted(clean_file.to_str().unwrap()).unwrap();
6727
6728        assert!(!text_conflicted, "Clean file should not have text conflict");
6729        assert!(
6730            !prop_conflicted,
6731            "Clean file should not have property conflict"
6732        );
6733        assert!(!tree_conflicted, "Clean file should not have tree conflict");
6734
6735        // Test 4: Non-existent file should error
6736        let nonexistent = wc1_path.join("nonexistent.txt");
6737        let result = wc_ctx.conflicted(nonexistent.to_str().unwrap());
6738        assert!(result.is_err(), "Non-existent file should return an error");
6739    }
6740
6741    #[test]
6742    fn test_copy_or_move() {
6743        let td = tempfile::tempdir().unwrap();
6744        let repo_path = td.path().join("repo");
6745        let wc_path = td.path().join("wc");
6746
6747        // Create a test repository
6748        crate::repos::Repos::create(&repo_path).unwrap();
6749
6750        // Check out working copy
6751        let mut client_ctx = crate::client::Context::new().unwrap();
6752        let url_str = crate::path_to_file_url(&repo_path);
6753        let url = crate::uri::Uri::new(&url_str).unwrap();
6754
6755        client_ctx
6756            .checkout(
6757                url,
6758                &wc_path,
6759                &crate::client::CheckoutOptions {
6760                    peg_revision: crate::Revision::Head,
6761                    revision: crate::Revision::Head,
6762                    depth: crate::Depth::Infinity,
6763                    ignore_externals: false,
6764                    allow_unver_obstructions: false,
6765                },
6766            )
6767            .unwrap();
6768
6769        // Create and add a test file
6770        let test_file = wc_path.join("test.txt");
6771        std::fs::write(&test_file, "test content").unwrap();
6772        client_ctx
6773            .add(&test_file, &crate::client::AddOptions::new())
6774            .unwrap();
6775
6776        // Test copy operation - will fail without write lock, but tests API
6777        let mut wc_ctx = Context::new().unwrap();
6778        let copy_dest = wc_path.join("test_copy.txt");
6779        let result = copy_or_move(&mut wc_ctx, &test_file, &copy_dest, false, false);
6780
6781        // Expected to fail without write lock, but verifies the function can be called
6782        assert!(result.is_err());
6783
6784        // Test move operation - also expected to fail without write lock
6785        let move_dest = wc_path.join("test_moved.txt");
6786        let result = copy_or_move(&mut wc_ctx, &test_file, &move_dest, true, false);
6787
6788        // Expected to fail without write lock
6789        assert!(result.is_err());
6790    }
6791
6792    #[test]
6793    fn test_revert() {
6794        let td = tempfile::tempdir().unwrap();
6795        let repo_path = td.path().join("repo");
6796        let wc_path = td.path().join("wc");
6797
6798        // Create a test repository
6799        crate::repos::Repos::create(&repo_path).unwrap();
6800
6801        // Check out working copy
6802        let mut client_ctx = crate::client::Context::new().unwrap();
6803        let url_str = crate::path_to_file_url(&repo_path);
6804        let url = crate::uri::Uri::new(&url_str).unwrap();
6805
6806        client_ctx
6807            .checkout(
6808                url,
6809                &wc_path,
6810                &crate::client::CheckoutOptions {
6811                    peg_revision: crate::Revision::Head,
6812                    revision: crate::Revision::Head,
6813                    depth: crate::Depth::Infinity,
6814                    ignore_externals: false,
6815                    allow_unver_obstructions: false,
6816                },
6817            )
6818            .unwrap();
6819
6820        // Create and add a test file
6821        let test_file = wc_path.join("test.txt");
6822        std::fs::write(&test_file, "original content").unwrap();
6823        client_ctx
6824            .add(&test_file, &crate::client::AddOptions::new())
6825            .unwrap();
6826
6827        // Test revert operation - will fail without write lock, but tests API
6828        let mut wc_ctx = Context::new().unwrap();
6829        let result = revert(
6830            &mut wc_ctx,
6831            &test_file,
6832            crate::Depth::Empty,
6833            false,
6834            false,
6835            false,
6836        );
6837
6838        // Expected to fail without write lock, but verifies the function can be called
6839        assert!(result.is_err());
6840    }
6841
6842    #[test]
6843    fn test_cleanup() {
6844        let td = tempfile::tempdir().unwrap();
6845        let repo_path = td.path().join("repo");
6846        let wc_path = td.path().join("wc");
6847
6848        // Create a test repository
6849        crate::repos::Repos::create(&repo_path).unwrap();
6850
6851        // Check out working copy
6852        let mut client_ctx = crate::client::Context::new().unwrap();
6853        let url_str = crate::path_to_file_url(&repo_path);
6854        let url = crate::uri::Uri::new(&url_str).unwrap();
6855
6856        client_ctx
6857            .checkout(
6858                url,
6859                &wc_path,
6860                &crate::client::CheckoutOptions {
6861                    peg_revision: crate::Revision::Head,
6862                    revision: crate::Revision::Head,
6863                    depth: crate::Depth::Infinity,
6864                    ignore_externals: false,
6865                    allow_unver_obstructions: false,
6866                },
6867            )
6868            .unwrap();
6869
6870        // Test cleanup operation
6871        let result = cleanup(&wc_path, false, false, false, false, false);
6872
6873        // Cleanup should succeed on a valid working copy
6874        result.unwrap();
6875
6876        // Test with break_locks
6877        cleanup(&wc_path, true, false, false, false, false).unwrap();
6878
6879        // Test with fix_recorded_timestamps
6880        cleanup(&wc_path, false, true, false, false, false).unwrap();
6881    }
6882
6883    #[test]
6884    fn test_get_actual_target() {
6885        let td = tempfile::tempdir().unwrap();
6886        let repo_path = td.path().join("repo");
6887        let wc_path = td.path().join("wc");
6888
6889        // Create a test repository
6890        crate::repos::Repos::create(&repo_path).unwrap();
6891
6892        // Check out working copy
6893        let mut client_ctx = crate::client::Context::new().unwrap();
6894        let url_str = crate::path_to_file_url(&repo_path);
6895        let url = crate::uri::Uri::new(&url_str).unwrap();
6896
6897        client_ctx
6898            .checkout(
6899                url,
6900                &wc_path,
6901                &crate::client::CheckoutOptions {
6902                    peg_revision: crate::Revision::Head,
6903                    revision: crate::Revision::Head,
6904                    depth: crate::Depth::Infinity,
6905                    ignore_externals: false,
6906                    allow_unver_obstructions: false,
6907                },
6908            )
6909            .unwrap();
6910
6911        // Test get_actual_target on the working copy root
6912        let (anchor, target) = get_actual_target(&wc_path).unwrap();
6913        // For a WC root, anchor should be the parent and target should be the directory name
6914        assert!(!anchor.is_empty() || !target.is_empty());
6915    }
6916
6917    #[test]
6918    fn test_walk_status() {
6919        let td = tempfile::tempdir().unwrap();
6920        let repo_path = td.path().join("repo");
6921        let wc_path = td.path().join("wc");
6922
6923        // Create a test repository
6924        crate::repos::Repos::create(&repo_path).unwrap();
6925
6926        // Check out working copy
6927        let mut client_ctx = crate::client::Context::new().unwrap();
6928        let url_str = crate::path_to_file_url(&repo_path);
6929        let url = crate::uri::Uri::new(&url_str).unwrap();
6930
6931        client_ctx
6932            .checkout(
6933                url,
6934                &wc_path,
6935                &crate::client::CheckoutOptions {
6936                    peg_revision: crate::Revision::Head,
6937                    revision: crate::Revision::Head,
6938                    depth: crate::Depth::Infinity,
6939                    ignore_externals: false,
6940                    allow_unver_obstructions: false,
6941                },
6942            )
6943            .unwrap();
6944
6945        // Create a test file
6946        let test_file = wc_path.join("test.txt");
6947        std::fs::write(&test_file, "test content").unwrap();
6948        client_ctx
6949            .add(&test_file, &crate::client::AddOptions::new())
6950            .unwrap();
6951
6952        // Test walk_status
6953        let mut wc_ctx = Context::new().unwrap();
6954        let mut status_count = 0;
6955        let result = wc_ctx.walk_status(
6956            &wc_path,
6957            crate::Depth::Infinity,
6958            true,  // get_all
6959            false, // no_ignore
6960            false, // ignore_text_mods
6961            None,  // ignore_patterns
6962            |_path, _status| {
6963                status_count += 1;
6964                Ok(())
6965            },
6966        );
6967
6968        result.unwrap();
6969        // Should have walked at least the root and the added file
6970        assert!(status_count >= 1, "Should have at least one status entry");
6971    }
6972
6973    #[test]
6974    fn test_wc_version() {
6975        let version = version();
6976        assert!(version.major() > 0);
6977    }
6978
6979    // NOTE: This test is disabled because set_adm_dir() modifies global state in the SVN C library,
6980    // which causes race conditions when tests run in parallel. Other tests creating working copies
6981    // expect .svn directories but get _svn directories instead, causing failures like:
6982    // "Can't create directory '/tmp/.../wc/_svn/pristine': No such file or directory"
6983    #[test]
6984    #[ignore]
6985    fn test_set_and_get_adm_dir() {
6986        // Test setting and getting admin dir
6987        set_adm_dir("_svn").unwrap();
6988
6989        let dir = get_adm_dir();
6990        assert_eq!(dir, "_svn");
6991
6992        // Reset to default
6993        set_adm_dir(".svn").unwrap();
6994
6995        let dir = get_adm_dir();
6996        assert_eq!(dir, ".svn");
6997    }
6998
6999    #[test]
7000    fn test_context_add() {
7001        let fixture = SvnTestFixture::new();
7002
7003        // Create a new file to add
7004        let new_file = fixture.wc_path.join("newfile.txt");
7005        std::fs::write(&new_file, b"test content").unwrap();
7006
7007        // Test Context::add() - svn_wc_add4() is a low-level function
7008        // that requires write locks to be acquired using private APIs.
7009        // Verify the binding exists and can be called.
7010        let mut wc_ctx = Context::new().unwrap();
7011        let new_file_abs = new_file.canonicalize().unwrap();
7012
7013        let result = wc_ctx.add(
7014            new_file_abs
7015                .to_str()
7016                .expect("file path should be valid UTF-8"),
7017            crate::Depth::Infinity,
7018            None,
7019            None,
7020        );
7021
7022        // svn_wc_add4 requires write locks managed externally (via private APIs).
7023        // The binding correctly calls the C function which will fail without locks.
7024        // This verifies the binding works and properly propagates errors.
7025        assert!(result.is_err());
7026        let err_str = format!("{:?}", result.err().unwrap());
7027        assert!(
7028            err_str.to_lowercase().contains("lock"),
7029            "Expected lock error, got: {}",
7030            err_str
7031        );
7032    }
7033
7034    #[test]
7035    fn test_context_relocate() {
7036        let tmp_dir = tempfile::tempdir().unwrap();
7037        let repos_path = tmp_dir.path().join("repo");
7038        let wc_path = tmp_dir.path().join("wc");
7039
7040        // Create a test repository
7041        crate::repos::Repos::create(&repos_path).unwrap();
7042        let url_str = crate::path_to_file_url(&repos_path);
7043        let url = crate::uri::Uri::new(&url_str).unwrap();
7044
7045        // Checkout
7046        let mut client_ctx = crate::client::Context::new().unwrap();
7047        client_ctx
7048            .checkout(
7049                url,
7050                &wc_path,
7051                &crate::client::CheckoutOptions {
7052                    peg_revision: crate::Revision::Head,
7053                    revision: crate::Revision::Head,
7054                    depth: crate::Depth::Infinity,
7055                    ignore_externals: false,
7056                    allow_unver_obstructions: false,
7057                },
7058            )
7059            .unwrap();
7060
7061        // Move the repository to a new location (simulating repository relocation)
7062        let repos_path2 = tmp_dir.path().join("repo_moved");
7063        std::fs::rename(&repos_path, &repos_path2).unwrap();
7064        let repos_url2 = crate::path_to_file_url(&repos_path2);
7065
7066        // Test relocate - should work since it's the same repository, different URL
7067        let mut wc_ctx = Context::new().unwrap();
7068        let result = wc_ctx.relocate(wc_path.to_str().unwrap(), &url_str, &repos_url2);
7069
7070        // Relocate should succeed when repository is moved
7071        assert!(result.is_ok(), "relocate() failed: {:?}", result.err());
7072    }
7073
7074    #[test]
7075    fn test_context_upgrade() {
7076        let fixture = SvnTestFixture::new();
7077
7078        // Test upgrade - should succeed (working copy is already in latest format)
7079        let mut wc_ctx = Context::new().unwrap();
7080        let result = wc_ctx.upgrade(fixture.wc_path_str());
7081        assert!(result.is_ok(), "upgrade() failed: {:?}", result.err());
7082    }
7083
7084    #[test]
7085    fn test_get_update_editor4_with_callbacks() {
7086        // Test that get_update_editor4 accepts callbacks
7087        let tmp_dir = tempfile::tempdir().unwrap();
7088        let repos_path = tmp_dir.path().join("repo");
7089        let wc_path = tmp_dir.path().join("wc");
7090
7091        // Create a test repository
7092        crate::repos::Repos::create(&repos_path).unwrap();
7093        let url_str = crate::path_to_file_url(&repos_path);
7094        let url = crate::uri::Uri::new(&url_str).unwrap();
7095
7096        // Checkout
7097        let mut client_ctx = crate::client::Context::new().unwrap();
7098        client_ctx
7099            .checkout(
7100                url,
7101                &wc_path,
7102                &crate::client::CheckoutOptions {
7103                    peg_revision: crate::Revision::Head,
7104                    revision: crate::Revision::Head,
7105                    depth: crate::Depth::Infinity,
7106                    ignore_externals: false,
7107                    allow_unver_obstructions: false,
7108                },
7109            )
7110            .unwrap();
7111
7112        // Track callback invocations
7113        let cancel_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
7114        let cancel_called_clone = cancel_called.clone();
7115
7116        let mut wc_ctx = Context::new().unwrap();
7117        let options = UpdateEditorOptions {
7118            cancel_func: Some(Box::new(move || {
7119                cancel_called_clone.store(true, std::sync::atomic::Ordering::SeqCst);
7120                Ok(())
7121            })),
7122            ..Default::default()
7123        };
7124        let result = get_update_editor4(&mut wc_ctx, wc_path.to_str().unwrap(), "", options);
7125
7126        assert!(
7127            result.is_ok(),
7128            "get_update_editor4 failed: {:?}",
7129            result.err()
7130        );
7131    }
7132
7133    #[test]
7134    fn test_get_switch_editor_with_callbacks() {
7135        // Test that get_switch_editor accepts callbacks
7136        let tmp_dir = tempfile::tempdir().unwrap();
7137        let repos_path = tmp_dir.path().join("repo");
7138        let wc_path = tmp_dir.path().join("wc");
7139
7140        // Create a test repository
7141        crate::repos::Repos::create(&repos_path).unwrap();
7142        let url_str = crate::path_to_file_url(&repos_path);
7143        let url = crate::uri::Uri::new(&url_str).unwrap();
7144
7145        // Checkout
7146        let mut client_ctx = crate::client::Context::new().unwrap();
7147        client_ctx
7148            .checkout(
7149                url.clone(),
7150                &wc_path,
7151                &crate::client::CheckoutOptions {
7152                    peg_revision: crate::Revision::Head,
7153                    revision: crate::Revision::Head,
7154                    depth: crate::Depth::Infinity,
7155                    ignore_externals: false,
7156                    allow_unver_obstructions: false,
7157                },
7158            )
7159            .unwrap();
7160
7161        // Track callback invocations
7162        let notify_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
7163        let notify_called_clone = notify_called.clone();
7164
7165        let mut wc_ctx = Context::new().unwrap();
7166        let options = SwitchEditorOptions {
7167            notify_func: Some(Box::new(move |_notify| {
7168                notify_called_clone.store(true, std::sync::atomic::Ordering::SeqCst);
7169            })),
7170            ..Default::default()
7171        };
7172        let result =
7173            wc_ctx.get_switch_editor(wc_path.to_str().unwrap(), "", url_str.as_str(), options);
7174
7175        assert!(
7176            result.is_ok(),
7177            "get_switch_editor failed: {:?}",
7178            result.err()
7179        );
7180    }
7181
7182    #[test]
7183    fn test_get_update_editor4_server_performs_filtering() {
7184        // Test that server_performs_filtering parameter is accepted
7185        let temp_dir = tempfile::tempdir().unwrap();
7186        std::fs::create_dir_all(temp_dir.path()).unwrap();
7187
7188        let mut wc_ctx = Context::new().unwrap();
7189
7190        // Test with server_performs_filtering = true
7191        let options = UpdateEditorOptions {
7192            server_performs_filtering: true,
7193            ..Default::default()
7194        };
7195        let result =
7196            get_update_editor4(&mut wc_ctx, temp_dir.path().to_str().unwrap(), "", options);
7197
7198        // Will fail without a real working copy, but tests that the parameter is accepted
7199        assert!(result.is_err());
7200    }
7201
7202    #[test]
7203    fn test_get_update_editor4_clean_checkout() {
7204        // Test that clean_checkout parameter is accepted
7205        let temp_dir = tempfile::tempdir().unwrap();
7206        std::fs::create_dir_all(temp_dir.path()).unwrap();
7207
7208        let mut wc_ctx = Context::new().unwrap();
7209
7210        // Test with clean_checkout = true
7211        let options = UpdateEditorOptions {
7212            clean_checkout: true,
7213            ..Default::default()
7214        };
7215        let result =
7216            get_update_editor4(&mut wc_ctx, temp_dir.path().to_str().unwrap(), "", options);
7217
7218        // Will fail without a real working copy, but tests that the parameter is accepted
7219        assert!(result.is_err());
7220    }
7221
7222    #[test]
7223    fn test_get_switch_editor_server_performs_filtering() {
7224        // Test that server_performs_filtering parameter is accepted
7225        let temp_dir = tempfile::tempdir().unwrap();
7226        std::fs::create_dir_all(temp_dir.path()).unwrap();
7227
7228        let mut wc_ctx = Context::new().unwrap();
7229
7230        // Test with server_performs_filtering = true
7231        let options = SwitchEditorOptions {
7232            server_performs_filtering: true,
7233            ..Default::default()
7234        };
7235        let result = wc_ctx.get_switch_editor(
7236            temp_dir.path().to_str().unwrap(),
7237            "",
7238            "http://example.com/svn/trunk",
7239            options,
7240        );
7241
7242        // Will fail without a real working copy, but tests that the parameter is accepted
7243        assert!(result.is_err());
7244    }
7245
7246    #[test]
7247    fn test_parse_externals_description() {
7248        // Test parsing a simple externals definition
7249        let desc = "^/trunk/lib lib";
7250        let items = parse_externals_description("/parent", desc, true).unwrap();
7251        assert_eq!(items.len(), 1);
7252        assert_eq!(items[0].target_dir, "lib");
7253        assert!(items[0].url.contains("trunk/lib"));
7254    }
7255
7256    #[test]
7257    fn test_parse_externals_description_with_revision() {
7258        // Test parsing externals with revision
7259        let desc = "-r42 http://example.com/svn/trunk external_dir";
7260        let items = parse_externals_description("/parent", desc, false).unwrap();
7261        assert_eq!(items.len(), 1);
7262        assert_eq!(items[0].target_dir, "external_dir");
7263        assert_eq!(items[0].url, "http://example.com/svn/trunk");
7264    }
7265
7266    #[test]
7267    fn test_parse_externals_description_empty() {
7268        // Test parsing empty externals
7269        let items = parse_externals_description("/parent", "", true).unwrap();
7270        assert!(items.is_empty());
7271    }
7272
7273    #[test]
7274    fn test_wc_add_function() {
7275        use tempfile::TempDir;
7276
7277        let temp_dir = TempDir::new().unwrap();
7278        let repos_path = temp_dir.path().join("repos");
7279        let wc_path = temp_dir.path().join("wc");
7280
7281        // Create a repository
7282        let _repos = crate::repos::Repos::create(&repos_path).unwrap();
7283
7284        // Create a working copy
7285        let url_str = crate::path_to_file_url(&repos_path);
7286        let url = crate::uri::Uri::new(&url_str).unwrap();
7287        let mut client_ctx = crate::client::Context::new().unwrap();
7288        client_ctx
7289            .checkout(
7290                url,
7291                &wc_path,
7292                &crate::client::CheckoutOptions {
7293                    peg_revision: crate::Revision::Head,
7294                    revision: crate::Revision::Head,
7295                    depth: crate::Depth::Infinity,
7296                    ignore_externals: false,
7297                    allow_unver_obstructions: false,
7298                },
7299            )
7300            .unwrap();
7301
7302        // Create a file in the working copy
7303        let file_path = wc_path.join("new_file.txt");
7304        std::fs::write(&file_path, "test content").unwrap();
7305
7306        // Test wc::add - svn_wc_add_from_disk3 requires write locks managed externally
7307        // This test verifies the function can be called and properly propagates errors
7308        let mut wc_ctx = Context::new().unwrap();
7309        let result = add(
7310            &mut wc_ctx,
7311            &file_path,
7312            crate::Depth::Infinity,
7313            false, // force
7314            false, // no_ignore
7315            false, // no_autoprops
7316            false, // add_parents
7317        );
7318
7319        // Should fail without write lock. If mutated to return Ok(()), this will fail
7320        assert!(result.is_err(), "add() should fail without write lock");
7321        let err_msg = format!("{:?}", result.unwrap_err());
7322        assert!(
7323            err_msg.to_lowercase().contains("lock") || err_msg.to_lowercase().contains("write"),
7324            "Expected lock-related error, got: {}",
7325            err_msg
7326        );
7327    }
7328
7329    #[test]
7330    fn test_wc_delete_keep_local() {
7331        use tempfile::TempDir;
7332
7333        let temp_dir = TempDir::new().unwrap();
7334        let repos_path = temp_dir.path().join("repos");
7335        let wc_path = temp_dir.path().join("wc");
7336
7337        // Create a repository
7338        let _repos = crate::repos::Repos::create(&repos_path).unwrap();
7339
7340        // Create a working copy
7341        let url_str = crate::path_to_file_url(&repos_path);
7342        let url = crate::uri::Uri::new(&url_str).unwrap();
7343        let mut client_ctx = crate::client::Context::new().unwrap();
7344        client_ctx
7345            .checkout(
7346                url,
7347                &wc_path,
7348                &crate::client::CheckoutOptions {
7349                    peg_revision: crate::Revision::Head,
7350                    revision: crate::Revision::Head,
7351                    depth: crate::Depth::Infinity,
7352                    ignore_externals: false,
7353                    allow_unver_obstructions: false,
7354                },
7355            )
7356            .unwrap();
7357
7358        // Create and add a file
7359        let file_path = wc_path.join("to_delete.txt");
7360        std::fs::write(&file_path, "test content").unwrap();
7361        client_ctx
7362            .add(&file_path, &crate::client::AddOptions::new())
7363            .unwrap();
7364
7365        // Commit the file
7366        let mut committed = false;
7367        client_ctx
7368            .commit(
7369                &[wc_path.to_str().unwrap()],
7370                &crate::client::CommitOptions::default(),
7371                std::collections::HashMap::from([("svn:log", "Add test file")]),
7372                None,
7373                &mut |_info| {
7374                    committed = true;
7375                    Ok(())
7376                },
7377            )
7378            .unwrap();
7379        assert!(committed);
7380
7381        // Verify file exists
7382        assert!(file_path.exists());
7383
7384        // Test wc::delete with keep_local=true
7385        // svn_wc_delete4 requires write locks managed externally
7386        let mut wc_ctx = Context::new().unwrap();
7387        let result = delete(
7388            &mut wc_ctx,
7389            &file_path,
7390            true,  // keep_local
7391            false, // delete_unversioned_target
7392        );
7393
7394        // Should fail without write lock. If mutated to return Ok(()), this will fail
7395        assert!(result.is_err(), "delete() should fail without write lock");
7396        let err_msg = format!("{:?}", result.unwrap_err());
7397        assert!(
7398            err_msg.to_lowercase().contains("lock") || err_msg.to_lowercase().contains("write"),
7399            "Expected lock-related error, got: {}",
7400            err_msg
7401        );
7402
7403        // File should still exist (wasn't deleted because operation failed)
7404        assert!(file_path.exists());
7405    }
7406
7407    #[test]
7408    fn test_wc_delete_remove_local() {
7409        use tempfile::TempDir;
7410
7411        let temp_dir = TempDir::new().unwrap();
7412        let repos_path = temp_dir.path().join("repos");
7413        let wc_path = temp_dir.path().join("wc");
7414
7415        // Create a repository
7416        let _repos = crate::repos::Repos::create(&repos_path).unwrap();
7417
7418        // Create a working copy
7419        let url_str = crate::path_to_file_url(&repos_path);
7420        let url = crate::uri::Uri::new(&url_str).unwrap();
7421        let mut client_ctx = crate::client::Context::new().unwrap();
7422        client_ctx
7423            .checkout(
7424                url,
7425                &wc_path,
7426                &crate::client::CheckoutOptions {
7427                    peg_revision: crate::Revision::Head,
7428                    revision: crate::Revision::Head,
7429                    depth: crate::Depth::Infinity,
7430                    ignore_externals: false,
7431                    allow_unver_obstructions: false,
7432                },
7433            )
7434            .unwrap();
7435
7436        // Create and add a file
7437        let file_path = wc_path.join("to_remove.txt");
7438        std::fs::write(&file_path, "test content").unwrap();
7439        client_ctx
7440            .add(&file_path, &crate::client::AddOptions::new())
7441            .unwrap();
7442
7443        // Commit the file
7444        let mut committed = false;
7445        client_ctx
7446            .commit(
7447                &[wc_path.to_str().unwrap()],
7448                &crate::client::CommitOptions::default(),
7449                std::collections::HashMap::from([("svn:log", "Add test file")]),
7450                None,
7451                &mut |_info| {
7452                    committed = true;
7453                    Ok(())
7454                },
7455            )
7456            .unwrap();
7457        assert!(committed);
7458        assert!(file_path.exists());
7459
7460        // Test wc::delete with keep_local=false
7461        // svn_wc_delete4 requires write locks managed externally
7462        let mut wc_ctx = Context::new().unwrap();
7463        let result = delete(
7464            &mut wc_ctx,
7465            &file_path,
7466            false, // keep_local
7467            false, // delete_unversioned_target
7468        );
7469
7470        // Should fail without write lock. If mutated to return Ok(()), this will fail
7471        assert!(result.is_err(), "delete() should fail without write lock");
7472        let err_msg = format!("{:?}", result.unwrap_err());
7473        assert!(
7474            err_msg.to_lowercase().contains("lock") || err_msg.to_lowercase().contains("write"),
7475            "Expected lock-related error, got: {}",
7476            err_msg
7477        );
7478
7479        // File should still exist (wasn't deleted because operation failed)
7480        assert!(file_path.exists());
7481    }
7482
7483    #[test]
7484    fn test_revision_status_empty_wc() {
7485        use tempfile::TempDir;
7486
7487        let temp_dir = TempDir::new().unwrap();
7488        let repos_path = temp_dir.path().join("repos");
7489        let wc_path = temp_dir.path().join("wc");
7490
7491        // Create a repository
7492        let _repos = crate::repos::Repos::create(&repos_path).unwrap();
7493
7494        // Create a working copy
7495        let url_str = crate::path_to_file_url(&repos_path);
7496        let url = crate::uri::Uri::new(&url_str).unwrap();
7497        let mut client_ctx = crate::client::Context::new().unwrap();
7498        client_ctx
7499            .checkout(
7500                url,
7501                &wc_path,
7502                &crate::client::CheckoutOptions {
7503                    peg_revision: crate::Revision::Head,
7504                    revision: crate::Revision::Head,
7505                    depth: crate::Depth::Infinity,
7506                    ignore_externals: false,
7507                    allow_unver_obstructions: false,
7508                },
7509            )
7510            .unwrap();
7511
7512        // Test revision_status on empty working copy
7513        let result = revision_status(&wc_path, None, false);
7514        assert!(result.is_ok(), "revision_status should succeed on empty WC");
7515
7516        let (min_rev, max_rev, is_switched, is_modified) = result.unwrap();
7517
7518        // Empty WC at revision 0
7519        assert_eq!(min_rev, 0, "min_rev should be 0 for empty WC");
7520        assert_eq!(max_rev, 0, "max_rev should be 0 for empty WC");
7521        assert_eq!(is_switched, false, "should not be switched");
7522        assert_eq!(is_modified, false, "should not be modified");
7523    }
7524
7525    #[test]
7526    fn test_revision_status_with_modifications() {
7527        use tempfile::TempDir;
7528
7529        let temp_dir = TempDir::new().unwrap();
7530        let repos_path = temp_dir.path().join("repos");
7531        let wc_path = temp_dir.path().join("wc");
7532
7533        // Create a repository
7534        let _repos = crate::repos::Repos::create(&repos_path).unwrap();
7535
7536        // Create a working copy
7537        let url_str = crate::path_to_file_url(&repos_path);
7538        let url = crate::uri::Uri::new(&url_str).unwrap();
7539        let mut client_ctx = crate::client::Context::new().unwrap();
7540        client_ctx
7541            .checkout(
7542                url,
7543                &wc_path,
7544                &crate::client::CheckoutOptions {
7545                    peg_revision: crate::Revision::Head,
7546                    revision: crate::Revision::Head,
7547                    depth: crate::Depth::Infinity,
7548                    ignore_externals: false,
7549                    allow_unver_obstructions: false,
7550                },
7551            )
7552            .unwrap();
7553
7554        // Add a file to create modifications
7555        let file_path = wc_path.join("test.txt");
7556        std::fs::write(&file_path, "test content").unwrap();
7557        client_ctx
7558            .add(&file_path, &crate::client::AddOptions::new())
7559            .unwrap();
7560
7561        // Check status - should show modifications
7562        let result = revision_status(&wc_path, None, false);
7563        assert!(
7564            result.is_ok(),
7565            "revision_status should succeed with modifications"
7566        );
7567
7568        let (min_rev, max_rev, is_switched, is_modified) = result.unwrap();
7569
7570        // Still at revision 0 but now modified
7571        assert_eq!(min_rev, 0, "min_rev should be 0");
7572        assert_eq!(max_rev, 0, "max_rev should be 0");
7573        assert_eq!(is_switched, false, "should not be switched");
7574        assert_eq!(is_modified, true, "should be modified after adding file");
7575    }
7576
7577    #[test]
7578    fn test_revision_status_after_commit() {
7579        use tempfile::TempDir;
7580
7581        let temp_dir = TempDir::new().unwrap();
7582        let repos_path = temp_dir.path().join("repos");
7583        let wc_path = temp_dir.path().join("wc");
7584
7585        // Create a repository
7586        let _repos = crate::repos::Repos::create(&repos_path).unwrap();
7587
7588        // Create a working copy
7589        let url_str = crate::path_to_file_url(&repos_path);
7590        let url = crate::uri::Uri::new(&url_str).unwrap();
7591        let mut client_ctx = crate::client::Context::new().unwrap();
7592        client_ctx
7593            .checkout(
7594                url,
7595                &wc_path,
7596                &crate::client::CheckoutOptions {
7597                    peg_revision: crate::Revision::Head,
7598                    revision: crate::Revision::Head,
7599                    depth: crate::Depth::Infinity,
7600                    ignore_externals: false,
7601                    allow_unver_obstructions: false,
7602                },
7603            )
7604            .unwrap();
7605
7606        // Add and commit a file
7607        let file_path = wc_path.join("test.txt");
7608        std::fs::write(&file_path, "test content").unwrap();
7609        client_ctx
7610            .add(&file_path, &crate::client::AddOptions::new())
7611            .unwrap();
7612
7613        let mut committed = false;
7614        client_ctx
7615            .commit(
7616                &[wc_path.to_str().unwrap()],
7617                &crate::client::CommitOptions::default(),
7618                std::collections::HashMap::from([("svn:log", "Add test file")]),
7619                None,
7620                &mut |_info| {
7621                    committed = true;
7622                    Ok(())
7623                },
7624            )
7625            .unwrap();
7626        assert!(committed, "commit should have been called");
7627
7628        // Check status after commit
7629        let result = revision_status(&wc_path, None, false);
7630        assert!(
7631            result.is_ok(),
7632            "revision_status should succeed after commit"
7633        );
7634
7635        let (min_rev, max_rev, is_switched, is_modified) = result.unwrap();
7636
7637        // After committing one file, max_rev should be 1 (the committed file)
7638        // min_rev might be 0 (the root dir) or 1 depending on implementation
7639        assert!(
7640            max_rev >= 1,
7641            "max_rev should be at least 1 after commit, got {}",
7642            max_rev
7643        );
7644        assert!(
7645            min_rev <= max_rev,
7646            "min_rev ({}) should be <= max_rev ({})",
7647            min_rev,
7648            max_rev
7649        );
7650        assert_eq!(is_switched, false, "should not be switched");
7651        assert_eq!(is_modified, false, "should not be modified after commit");
7652    }
7653
7654    #[test]
7655    fn test_resolve_conflict_function() {
7656        use tempfile::TempDir;
7657
7658        let temp_dir = TempDir::new().unwrap();
7659
7660        // Test that resolve_conflict fails on a non-WC path
7661        // This verifies it's actually calling the C library, not just returning Ok(())
7662        let non_wc_path = temp_dir.path().join("not_a_wc");
7663        std::fs::create_dir(&non_wc_path).unwrap();
7664
7665        let mut wc_ctx = Context::new().unwrap();
7666        let result = resolve_conflict(
7667            &mut wc_ctx,
7668            &non_wc_path,
7669            crate::Depth::Empty,
7670            true,  // resolve_text
7671            true,  // resolve_props
7672            false, // resolve_tree
7673            ConflictChoice::Postpone,
7674        );
7675
7676        // Should fail since it's not a working copy
7677        // If mutated to always return Ok(()), this test will fail
7678        assert!(
7679            result.is_err(),
7680            "resolve_conflict() should fail on non-WC path, proving it calls the C library"
7681        );
7682    }
7683
7684    #[test]
7685    fn test_status_methods() {
7686        use tempfile::TempDir;
7687
7688        let temp_dir = TempDir::new().unwrap();
7689        let repos_path = temp_dir.path().join("repos");
7690        let wc_path = temp_dir.path().join("wc");
7691
7692        // Create a repository
7693        let _repos = crate::repos::Repos::create(&repos_path).unwrap();
7694
7695        // Create a working copy
7696        let url_str = crate::path_to_file_url(&repos_path);
7697        let url = crate::uri::Uri::new(&url_str).unwrap();
7698        let mut client_ctx = crate::client::Context::new().unwrap();
7699        client_ctx
7700            .checkout(
7701                url,
7702                &wc_path,
7703                &crate::client::CheckoutOptions {
7704                    peg_revision: crate::Revision::Head,
7705                    revision: crate::Revision::Head,
7706                    depth: crate::Depth::Infinity,
7707                    ignore_externals: false,
7708                    allow_unver_obstructions: false,
7709                },
7710            )
7711            .unwrap();
7712
7713        // Add and commit a file
7714        let file_path = wc_path.join("test.txt");
7715        std::fs::write(&file_path, "test content").unwrap();
7716        client_ctx
7717            .add(&file_path, &crate::client::AddOptions::new())
7718            .unwrap();
7719
7720        let mut committed = false;
7721        client_ctx
7722            .commit(
7723                &[wc_path.to_str().unwrap()],
7724                &crate::client::CommitOptions::default(),
7725                std::collections::HashMap::from([("svn:log", "Add test file")]),
7726                None,
7727                &mut |_info| {
7728                    committed = true;
7729                    Ok(())
7730                },
7731            )
7732            .unwrap();
7733        assert!(committed);
7734
7735        // Get status of the file and test Status methods within the callback
7736        let mut wc_ctx = Context::new().unwrap();
7737        let mut found_status = false;
7738        wc_ctx
7739            .walk_status(
7740                &file_path,
7741                crate::Depth::Empty,
7742                true,  // get_all
7743                false, // no_ignore
7744                false, // ignore_text_mods
7745                None,  // ignore_patterns
7746                |_path, status| {
7747                    found_status = true;
7748
7749                    // Test Status methods
7750                    assert_eq!(status.copied(), false, "File should not be copied");
7751                    assert_eq!(status.switched(), false, "File should not be switched");
7752                    assert_eq!(status.locked(), false, "File should not be locked");
7753                    assert_eq!(
7754                        status.node_status(),
7755                        StatusKind::Normal,
7756                        "File should be normal"
7757                    );
7758                    assert!(status.revision().0 >= 0, "Revision should be >= 0");
7759                    assert_eq!(
7760                        status.repos_relpath(),
7761                        Some("test.txt".to_string()),
7762                        "repos_relpath should match"
7763                    );
7764                    Ok(())
7765                },
7766            )
7767            .unwrap();
7768
7769        assert!(found_status, "Should have found file status");
7770    }
7771
7772    #[test]
7773    fn test_status_added_file() {
7774        let mut fixture = SvnTestFixture::new();
7775        let file_path = fixture.add_file("added.txt", "test content");
7776
7777        // Get status of the added file and verify within callback
7778        let mut wc_ctx = Context::new().unwrap();
7779        let mut found_status = false;
7780        wc_ctx
7781            .walk_status(
7782                &file_path,
7783                crate::Depth::Empty,
7784                true,  // get_all
7785                false, // no_ignore
7786                false, // ignore_text_mods
7787                None,  // ignore_patterns
7788                |_path, status| {
7789                    found_status = true;
7790
7791                    // Verify it's added
7792                    assert_eq!(
7793                        status.node_status(),
7794                        StatusKind::Added,
7795                        "File should be added"
7796                    );
7797                    assert_eq!(
7798                        status.copied(),
7799                        false,
7800                        "Added file should not be marked as copied"
7801                    );
7802                    Ok(())
7803                },
7804            )
7805            .unwrap();
7806
7807        assert!(found_status, "Should have found file status");
7808    }
7809
7810    #[test]
7811    fn test_cleanup_actually_executes() {
7812        use tempfile::TempDir;
7813
7814        // Test that cleanup() returns an error when called on a non-existent path
7815        // This verifies it's actually calling the C library, not just returning Ok(())
7816        let temp_dir = TempDir::new().unwrap();
7817        let non_existent = temp_dir.path().join("does_not_exist");
7818
7819        let result = cleanup(&non_existent, false, false, false, false, false);
7820        assert!(
7821            result.is_err(),
7822            "cleanup() should fail on non-existent path, proving it calls the C library"
7823        );
7824
7825        // Test that cleanup() succeeds on a valid working copy
7826        let fixture = SvnTestFixture::new();
7827
7828        // Cleanup should succeed on a valid working copy
7829        let result = cleanup(&fixture.wc_path, false, false, false, false, false);
7830        assert!(
7831            result.is_ok(),
7832            "cleanup() should succeed on valid working copy"
7833        );
7834
7835        // Test with different options to verify they're passed through
7836        let result = cleanup(&fixture.wc_path, true, true, true, true, false);
7837        assert!(
7838            result.is_ok(),
7839            "cleanup() with all options should succeed on valid working copy"
7840        );
7841    }
7842
7843    #[test]
7844    fn test_context_check_wc_returns_format_number() {
7845        let fixture = SvnTestFixture::new();
7846
7847        // Test check_wc on the working copy
7848        let mut wc_ctx = Context::new().unwrap();
7849        let format_num = wc_ctx.check_wc(fixture.wc_path_str()).unwrap();
7850
7851        // The format number should be a valid SVN working copy format
7852        // SVN 1.7+ uses format 12 or higher
7853        // This catches mutations that always return 0, 1, or -1
7854        assert!(
7855            format_num > 10,
7856            "Working copy format should be > 10 for modern SVN, got {}",
7857            format_num
7858        );
7859        assert!(
7860            format_num < 100,
7861            "Working copy format should be reasonable (< 100), got {}",
7862            format_num
7863        );
7864    }
7865
7866    #[test]
7867    fn test_free_function_check_wc() {
7868        use tempfile::TempDir;
7869
7870        let temp_dir = TempDir::new().unwrap();
7871
7872        // Test on non-WC directory - should return None
7873        let non_wc = temp_dir.path().join("not_a_wc");
7874        std::fs::create_dir(&non_wc).unwrap();
7875
7876        let result = check_wc(&non_wc);
7877        assert!(
7878            result.is_ok(),
7879            "check_wc should succeed on non-WC directory"
7880        );
7881        assert_eq!(
7882            result.unwrap(),
7883            None,
7884            "check_wc should return None for non-WC directory"
7885        );
7886
7887        // Test on actual WC - should return Some with valid format number
7888        let fixture = SvnTestFixture::new();
7889        let result = check_wc(&fixture.wc_path);
7890        assert!(result.is_ok(), "check_wc should succeed on valid WC");
7891
7892        let format_opt = result.unwrap();
7893        assert!(
7894            format_opt.is_some(),
7895            "check_wc should return Some for valid WC, got None"
7896        );
7897
7898        let format_num = format_opt.unwrap();
7899        assert!(
7900            format_num > 10,
7901            "WC format should be > 10 for modern SVN, got {}",
7902            format_num
7903        );
7904        assert!(
7905            format_num < 100,
7906            "WC format should be reasonable (< 100), got {}",
7907            format_num
7908        );
7909    }
7910
7911    #[test]
7912    fn test_upgrade_actually_executes() {
7913        use tempfile::TempDir;
7914
7915        let temp_dir = TempDir::new().unwrap();
7916        let non_wc = temp_dir.path().join("not_a_wc");
7917        std::fs::create_dir(&non_wc).unwrap();
7918
7919        let mut wc_ctx = Context::new().unwrap();
7920        let result = wc_ctx.upgrade(non_wc.to_str().unwrap());
7921
7922        // Should fail on non-WC directory, proving it calls the C library
7923        assert!(
7924            result.is_err(),
7925            "upgrade() should fail on non-WC directory, proving it calls the C library"
7926        );
7927    }
7928
7929    #[test]
7930    fn test_prop_set_actually_executes() {
7931        let mut fixture = SvnTestFixture::new();
7932        let file_path = fixture.add_file("test.txt", "test content");
7933
7934        let mut wc_ctx = Context::new().unwrap();
7935
7936        // Verify property doesn't exist before
7937        assert_eq!(
7938            wc_ctx.prop_get(&file_path, "test:prop").unwrap(),
7939            None,
7940            "Property should not exist before"
7941        );
7942
7943        // Set property using CLIENT API (which handles locking)
7944        fixture
7945            .client_ctx
7946            .propset(
7947                "test:prop",
7948                Some(b"test value"),
7949                file_path.to_str().expect("test path should be valid UTF-8"),
7950                &crate::client::PropSetOptions::default(),
7951            )
7952            .unwrap();
7953
7954        // Verify property was actually set by reading back with WC API
7955        assert_eq!(
7956            wc_ctx.prop_get(&file_path, "test:prop").unwrap().as_deref(),
7957            Some(&b"test value"[..]),
7958            "client propset() should actually set the property"
7959        );
7960    }
7961
7962    #[test]
7963    fn test_relocate_actually_executes() {
7964        let mut fixture = SvnTestFixture::new();
7965        let old_url = fixture.url.clone();
7966
7967        // Verify original URL
7968        assert_eq!(fixture.get_wc_url(), old_url);
7969
7970        // Create a second repository to relocate to
7971        let (_repos_path2, new_url) = create_repo(fixture.temp_dir.path(), "repos2");
7972
7973        // Relocate to the new repository URL
7974        let mut wc_ctx = Context::new().unwrap();
7975        wc_ctx
7976            .relocate(fixture.wc_path_str(), &old_url, &new_url)
7977            .unwrap();
7978
7979        // Verify URL was actually changed
7980        assert_eq!(
7981            fixture.get_wc_url(),
7982            new_url,
7983            "relocate() should actually change the repository URL"
7984        );
7985    }
7986
7987    #[test]
7988    fn test_add_lock_actually_executes() {
7989        use tempfile::TempDir;
7990
7991        let temp_dir = TempDir::new().unwrap();
7992        let non_wc = temp_dir.path().join("not_a_wc");
7993        std::fs::create_dir(&non_wc).unwrap();
7994        let file_path = non_wc.join("file.txt");
7995        std::fs::write(&file_path, "test").unwrap();
7996
7997        let mut wc_ctx = Context::new().unwrap();
7998
7999        // Create a dummy lock (using null pointer since we expect failure anyway)
8000        let lock = Lock::from_ptr(std::ptr::null());
8001
8002        let result = wc_ctx.add_lock(&file_path, &lock);
8003
8004        // Should fail on non-WC file, proving it calls the C library
8005        assert!(
8006            result.is_err(),
8007            "add_lock() should fail on non-WC file, proving it calls the C library"
8008        );
8009    }
8010
8011    #[test]
8012    fn test_remove_lock_actually_executes() {
8013        use tempfile::TempDir;
8014
8015        let temp_dir = TempDir::new().unwrap();
8016        let non_wc = temp_dir.path().join("not_a_wc");
8017        std::fs::create_dir(&non_wc).unwrap();
8018        let file_path = non_wc.join("file.txt");
8019        std::fs::write(&file_path, "test").unwrap();
8020
8021        let mut wc_ctx = Context::new().unwrap();
8022        let result = wc_ctx.remove_lock(&file_path);
8023
8024        // Should fail on non-WC file, proving it calls the C library
8025        assert!(
8026            result.is_err(),
8027            "remove_lock() should fail on non-WC file, proving it calls the C library"
8028        );
8029    }
8030
8031    #[test]
8032    fn test_ensure_adm_actually_executes() {
8033        use tempfile::TempDir;
8034
8035        let temp_dir = TempDir::new().unwrap();
8036        let wc_path = temp_dir.path().join("new_wc");
8037
8038        // Ensure the directory exists (ensure_adm requires it)
8039        std::fs::create_dir(&wc_path).unwrap();
8040
8041        // Verify .svn doesn't exist before
8042        let svn_dir = wc_path.join(".svn");
8043        assert!(!svn_dir.exists(), ".svn should not exist before ensure_adm");
8044
8045        let result = ensure_adm(
8046            &wc_path,
8047            "test-uuid",
8048            "file:///tmp/test-repo",
8049            "file:///tmp/test-repo",
8050            1,
8051        );
8052
8053        // Should succeed
8054        assert!(result.is_ok(), "ensure_adm() should succeed: {:?}", result);
8055
8056        // Verify .svn directory was created
8057        assert!(
8058            svn_dir.exists(),
8059            "ensure_adm() should create .svn directory"
8060        );
8061    }
8062
8063    #[test]
8064    fn test_process_committed_queue_actually_executes() {
8065        use tempfile::TempDir;
8066
8067        let temp_dir = TempDir::new().unwrap();
8068        let non_wc = temp_dir.path().join("not_a_wc");
8069        std::fs::create_dir(&non_wc).unwrap();
8070
8071        let mut wc_ctx = Context::new().unwrap();
8072        let mut queue = CommittedQueue::new();
8073
8074        let result = wc_ctx.process_committed_queue(
8075            &mut queue,
8076            crate::Revnum(1),
8077            Some("2024-01-01T00:00:00.000000Z"),
8078            Some("author"),
8079        );
8080
8081        // Empty queue should succeed, but at least we're testing it executes
8082        // The mutation would return Ok(()) without calling the C library
8083        // With a real queue this would do work, but empty queue is still valid
8084        assert!(
8085            result.is_ok(),
8086            "process_committed_queue() should succeed on empty queue"
8087        );
8088
8089        // The real test is that it doesn't just return Ok(()) - it actually
8090        // calls the C library. We can't easily test failure without a complex setup,
8091        // but the success path still proves it's not just returning Ok(())
8092        // because the C library function would fail with invalid arguments
8093    }
8094
8095    /// A minimal `DiffCallbacks` implementation that records which paths were
8096    /// reported as changed or added during a diff.
8097    struct RecordingDiffCallbacks {
8098        changed_files: Vec<String>,
8099        added_files: Vec<String>,
8100        deleted_files: Vec<String>,
8101    }
8102
8103    impl RecordingDiffCallbacks {
8104        fn new() -> Self {
8105            Self {
8106                changed_files: Vec::new(),
8107                added_files: Vec::new(),
8108                deleted_files: Vec::new(),
8109            }
8110        }
8111    }
8112
8113    impl DiffCallbacks for RecordingDiffCallbacks {
8114        fn file_opened(
8115            &mut self,
8116            _path: &str,
8117            _rev: crate::Revnum,
8118        ) -> Result<(bool, bool), crate::Error<'static>> {
8119            Ok((false, false))
8120        }
8121
8122        fn file_changed(
8123            &mut self,
8124            change: &FileChange<'_>,
8125        ) -> Result<(NotifyState, NotifyState, bool), crate::Error<'static>> {
8126            self.changed_files.push(change.path.to_string());
8127            Ok((NotifyState::Changed, NotifyState::Unchanged, false))
8128        }
8129
8130        fn file_added(
8131            &mut self,
8132            change: &FileChange<'_>,
8133            _copyfrom_path: Option<&str>,
8134            _copyfrom_revision: crate::Revnum,
8135        ) -> Result<(NotifyState, NotifyState, bool), crate::Error<'static>> {
8136            self.added_files.push(change.path.to_string());
8137            Ok((NotifyState::Changed, NotifyState::Unchanged, false))
8138        }
8139
8140        fn file_deleted(
8141            &mut self,
8142            path: &str,
8143            _tmpfile1: Option<&str>,
8144            _tmpfile2: Option<&str>,
8145            _mimetype1: Option<&str>,
8146            _mimetype2: Option<&str>,
8147        ) -> Result<(NotifyState, bool), crate::Error<'static>> {
8148            self.deleted_files.push(path.to_string());
8149            Ok((NotifyState::Changed, false))
8150        }
8151
8152        fn dir_deleted(
8153            &mut self,
8154            _path: &str,
8155        ) -> Result<(NotifyState, bool), crate::Error<'static>> {
8156            Ok((NotifyState::Unchanged, false))
8157        }
8158
8159        fn dir_opened(
8160            &mut self,
8161            _path: &str,
8162            _rev: crate::Revnum,
8163        ) -> Result<(bool, bool, bool), crate::Error<'static>> {
8164            Ok((false, false, false))
8165        }
8166
8167        fn dir_added(
8168            &mut self,
8169            _path: &str,
8170            _rev: crate::Revnum,
8171            _copyfrom_path: Option<&str>,
8172            _copyfrom_revision: crate::Revnum,
8173        ) -> Result<(NotifyState, bool, bool, bool), crate::Error<'static>> {
8174            Ok((NotifyState::Changed, false, false, false))
8175        }
8176
8177        fn dir_props_changed(
8178            &mut self,
8179            _path: &str,
8180            _dir_was_added: bool,
8181            _prop_changes: &[PropChange],
8182        ) -> Result<(NotifyState, bool), crate::Error<'static>> {
8183            Ok((NotifyState::Unchanged, false))
8184        }
8185
8186        fn dir_closed(
8187            &mut self,
8188            _path: &str,
8189            _dir_was_added: bool,
8190        ) -> Result<(NotifyState, NotifyState, bool), crate::Error<'static>> {
8191            Ok((NotifyState::Unchanged, NotifyState::Unchanged, false))
8192        }
8193    }
8194
8195    #[test]
8196    fn test_diff_reports_modified_file() {
8197        let mut fixture = SvnTestFixture::new();
8198        fixture.add_file("test.txt", "original content\n");
8199        fixture.commit();
8200
8201        // Modify the file locally without committing.
8202        std::fs::write(fixture.wc_path.join("test.txt"), "modified content\n").unwrap();
8203
8204        let mut callbacks = RecordingDiffCallbacks::new();
8205        let mut wc_ctx = Context::new().unwrap();
8206        wc_ctx
8207            .diff(&fixture.wc_path, &DiffOptions::default(), &mut callbacks)
8208            .expect("diff() should succeed on a working copy with local modifications");
8209
8210        // test.txt has local modifications so it must appear exactly once as changed.
8211        assert_eq!(
8212            callbacks.changed_files,
8213            vec!["test.txt"],
8214            "only the modified file should be reported as changed"
8215        );
8216        assert!(
8217            callbacks.added_files.is_empty(),
8218            "no files should be reported as added"
8219        );
8220        assert!(
8221            callbacks.deleted_files.is_empty(),
8222            "no files should be reported as deleted"
8223        );
8224    }
8225
8226    #[test]
8227    fn test_diff_reports_added_file() {
8228        let mut fixture = SvnTestFixture::new();
8229        // Commit a file so the WC is at revision 1.
8230        fixture.add_file("existing.txt", "exists\n");
8231        fixture.commit();
8232
8233        // Add a new file but do not commit it.
8234        fixture.add_file("new.txt", "brand new\n");
8235
8236        let mut callbacks = RecordingDiffCallbacks::new();
8237        let mut wc_ctx = Context::new().unwrap();
8238        wc_ctx
8239            .diff(&fixture.wc_path, &DiffOptions::default(), &mut callbacks)
8240            .expect("diff() should succeed");
8241
8242        // new.txt should appear as added.
8243        assert_eq!(
8244            callbacks.added_files,
8245            vec!["new.txt"],
8246            "the newly added file should be reported as added"
8247        );
8248        assert!(
8249            callbacks.changed_files.is_empty(),
8250            "no files should be reported as changed"
8251        );
8252    }
8253
8254    #[test]
8255    fn test_diff_clean_working_copy_reports_nothing() {
8256        let mut fixture = SvnTestFixture::new();
8257        fixture.add_file("clean.txt", "clean content\n");
8258        fixture.commit();
8259
8260        let mut callbacks = RecordingDiffCallbacks::new();
8261        let mut wc_ctx = Context::new().unwrap();
8262        wc_ctx
8263            .diff(&fixture.wc_path, &DiffOptions::default(), &mut callbacks)
8264            .expect("diff() should succeed on a clean working copy");
8265
8266        assert!(
8267            callbacks.changed_files.is_empty(),
8268            "no changed files in a clean WC"
8269        );
8270        assert!(
8271            callbacks.added_files.is_empty(),
8272            "no added files in a clean WC"
8273        );
8274        assert!(
8275            callbacks.deleted_files.is_empty(),
8276            "no deleted files in a clean WC"
8277        );
8278    }
8279
8280    #[test]
8281    fn test_merge_requires_write_lock() {
8282        // svn_wc_merge5 requires the directory containing target_abspath to be
8283        // write-locked by the wc_ctx.  Acquiring a write lock requires private
8284        // SVN APIs that are not part of the public interface; we therefore verify
8285        // that merge() propagates the expected lock error rather than silently
8286        // succeeding or returning a generic error.
8287        let mut fixture = SvnTestFixture::new();
8288        let target_path = fixture.add_file("target.txt", "line 1\nline 2\nline 3\n");
8289        fixture.commit();
8290
8291        let left_path = fixture.temp_dir.path().join("left.txt");
8292        let right_path = fixture.temp_dir.path().join("right.txt");
8293        std::fs::write(&left_path, "line 1\nline 2\nline 3\n").unwrap();
8294        std::fs::write(&right_path, "line 1\nline 2 modified\nline 3\n").unwrap();
8295
8296        let mut wc_ctx = Context::new().unwrap();
8297        let result = wc_ctx.merge(
8298            &left_path,
8299            &right_path,
8300            &target_path,
8301            Some(".left"),
8302            Some(".right"),
8303            Some(".working"),
8304            &[],
8305            &MergeOptions::default(),
8306        );
8307
8308        // The C library must report a write-lock error, proving our wrapper
8309        // actually reached svn_wc_merge5 rather than returning Ok(()) early.
8310        assert!(
8311            result.is_err(),
8312            "merge() must fail without a write lock; a mutation returning Ok(()) would be caught here"
8313        );
8314        let err_msg = format!("{:?}", result.unwrap_err());
8315        assert!(
8316            err_msg.to_lowercase().contains("lock") || err_msg.to_lowercase().contains("write"),
8317            "expected a write-lock error, got: {}",
8318            err_msg
8319        );
8320    }
8321
8322    #[test]
8323    fn test_merge_with_prop_diff_requires_write_lock() {
8324        // Verify merge() with non-empty prop_diff also reaches svn_wc_merge5
8325        // (i.e., the prop_diff array is constructed correctly and the call is made).
8326        let mut fixture = SvnTestFixture::new();
8327        let target_path = fixture.add_file("target.txt", "content\n");
8328        fixture.commit();
8329
8330        let left_path = fixture.temp_dir.path().join("left.txt");
8331        let right_path = fixture.temp_dir.path().join("right.txt");
8332        std::fs::write(&left_path, "content\n").unwrap();
8333        std::fs::write(&right_path, "content modified\n").unwrap();
8334
8335        let prop_changes = vec![PropChange {
8336            name: "svn:eol-style".to_string(),
8337            value: Some(b"native".to_vec()),
8338        }];
8339
8340        let mut wc_ctx = Context::new().unwrap();
8341        let result = wc_ctx.merge(
8342            &left_path,
8343            &right_path,
8344            &target_path,
8345            None,
8346            None,
8347            None,
8348            &prop_changes,
8349            &MergeOptions::default(),
8350        );
8351
8352        assert!(
8353            result.is_err(),
8354            "merge() with prop_diff must fail without a write lock"
8355        );
8356        let err_msg = format!("{:?}", result.unwrap_err());
8357        assert!(
8358            err_msg.to_lowercase().contains("lock") || err_msg.to_lowercase().contains("write"),
8359            "expected a write-lock error, got: {}",
8360            err_msg
8361        );
8362    }
8363
8364    #[test]
8365    fn test_merge_props_requires_write_lock() {
8366        // svn_wc_merge_props3 requires the working copy path to be write-locked.
8367        // Without a write lock we expect an error, which proves the wrapper
8368        // actually reached svn_wc_merge_props3.
8369        let mut fixture = SvnTestFixture::new();
8370        let file_path = fixture.add_file("propmerge.txt", "content\n");
8371        fixture.commit();
8372
8373        let prop_changes = vec![PropChange {
8374            name: "svn:keywords".to_string(),
8375            value: Some(b"Id".to_vec()),
8376        }];
8377
8378        let mut wc_ctx = Context::new().unwrap();
8379        let result = wc_ctx.merge_props(&file_path, None, &prop_changes, false, None, None);
8380
8381        assert!(
8382            result.is_err(),
8383            "merge_props() must fail without a write lock; a mutation returning Ok(()) would be caught here"
8384        );
8385        let err_msg = format!("{:?}", result.unwrap_err());
8386        assert!(
8387            err_msg.to_lowercase().contains("lock")
8388                || err_msg.to_lowercase().contains("write")
8389                || err_msg.to_lowercase().contains("path"),
8390            "expected a write-lock or path error, got: {}",
8391            err_msg
8392        );
8393    }
8394
8395    #[test]
8396    fn test_revert_requires_write_lock() {
8397        // svn_wc_revert6 requires the working copy paths to be write-locked in
8398        // wc_ctx.  Acquiring a write lock requires private SVN APIs; verify
8399        // that revert() propagates the expected lock error rather than
8400        // returning Ok(()) silently.
8401        let mut fixture = SvnTestFixture::new();
8402        let file_path = fixture.add_file("revert_me.txt", "original\n");
8403        fixture.commit();
8404
8405        std::fs::write(&file_path, "changed\n").unwrap();
8406
8407        let mut wc_ctx = Context::new().unwrap();
8408        let result = wc_ctx.revert(
8409            &file_path,
8410            &RevertOptions {
8411                depth: crate::Depth::Empty,
8412                ..Default::default()
8413            },
8414        );
8415
8416        assert!(
8417            result.is_err(),
8418            "revert() must fail without a write lock; a mutation returning Ok(()) would be caught here"
8419        );
8420        let err_msg = format!("{:?}", result.unwrap_err());
8421        assert!(
8422            err_msg.to_lowercase().contains("lock") || err_msg.to_lowercase().contains("write"),
8423            "expected a write-lock error, got: {}",
8424            err_msg
8425        );
8426    }
8427
8428    #[test]
8429    fn test_revert_with_added_keep_local_false_requires_write_lock() {
8430        // Verify that the added_keep_local=false path also reaches svn_wc_revert6.
8431        let mut fixture = SvnTestFixture::new();
8432        fixture.add_file("existing.txt", "exists\n");
8433        fixture.commit();
8434
8435        let new_file = fixture.add_file("new_file.txt", "new\n");
8436
8437        let mut wc_ctx = Context::new().unwrap();
8438        let result = wc_ctx.revert(
8439            &new_file,
8440            &RevertOptions {
8441                depth: crate::Depth::Empty,
8442                added_keep_local: false,
8443                ..Default::default()
8444            },
8445        );
8446
8447        assert!(
8448            result.is_err(),
8449            "revert() with added_keep_local=false must fail without a write lock"
8450        );
8451        let err_msg = format!("{:?}", result.unwrap_err());
8452        assert!(
8453            err_msg.to_lowercase().contains("lock") || err_msg.to_lowercase().contains("write"),
8454            "expected a write-lock error, got: {}",
8455            err_msg
8456        );
8457    }
8458
8459    #[test]
8460    fn test_revert_with_non_matching_changelist_is_noop() {
8461        // When the changelist filter matches no items, svn_wc_revert6 is a
8462        // no-op and returns success without needing a write lock.  This
8463        // verifies that the changelist array is constructed correctly (bad
8464        // construction would cause a crash or different error).
8465        let mut fixture = SvnTestFixture::new();
8466        let file_path = fixture.add_file("cl_file.txt", "content\n");
8467        fixture.commit();
8468
8469        std::fs::write(&file_path, "modified\n").unwrap();
8470
8471        let mut wc_ctx = Context::new().unwrap();
8472        // "no-such-changelist" doesn't match the file (which has no changelist),
8473        // so this is a no-op and must succeed.
8474        wc_ctx
8475            .revert(
8476                &fixture.wc_path,
8477                &RevertOptions {
8478                    depth: crate::Depth::Infinity,
8479                    changelists: vec!["no-such-changelist".to_string()],
8480                    ..Default::default()
8481                },
8482            )
8483            .expect("revert() with non-matching changelist should succeed as a no-op");
8484
8485        // File should still be modified (nothing was reverted).
8486        assert_eq!(
8487            std::fs::read_to_string(&file_path).unwrap(),
8488            "modified\n",
8489            "file should be unchanged when changelist filter matches nothing"
8490        );
8491    }
8492
8493    #[test]
8494    fn test_copy_requires_write_lock() {
8495        // svn_wc_copy3 requires the parent directory of dst_abspath to be
8496        // write-locked.  Acquiring a write lock requires private SVN APIs;
8497        // verify that copy() propagates the expected lock error.
8498        let mut fixture = SvnTestFixture::new();
8499        let src_path = fixture.add_file("src.txt", "source content\n");
8500        fixture.commit();
8501
8502        let dst_path = fixture.wc_path.join("dst.txt");
8503
8504        let mut wc_ctx = Context::new().unwrap();
8505        let result = wc_ctx.copy(&src_path, &dst_path, false);
8506
8507        assert!(
8508            result.is_err(),
8509            "copy() must fail without a write lock; a mutation returning Ok(()) would be caught here"
8510        );
8511        let err_msg = format!("{:?}", result.unwrap_err());
8512        assert!(
8513            err_msg.to_lowercase().contains("lock") || err_msg.to_lowercase().contains("write"),
8514            "expected a write-lock error, got: {}",
8515            err_msg
8516        );
8517    }
8518
8519    #[test]
8520    fn test_set_changelist_assigns_and_clears() {
8521        let mut fixture = SvnTestFixture::new();
8522        let file_path = fixture.add_file("hello.txt", "hello\n");
8523        fixture.commit();
8524
8525        let mut wc_ctx = Context::new().unwrap();
8526
8527        // Assign to a changelist.
8528        wc_ctx
8529            .set_changelist(&file_path, Some("my-cl"), crate::Depth::Empty, &[])
8530            .expect("set_changelist() should succeed for a versioned file");
8531
8532        // Verify the file now appears in that changelist.  Use changelist_filter
8533        // so SVN only reports nodes with that specific changelist (avoiding the
8534        // "all nodes visited" behaviour when no filter is given).
8535        let filter = vec!["my-cl".to_string()];
8536        let mut found: Vec<(String, Option<String>)> = Vec::new();
8537        wc_ctx
8538            .get_changelists(
8539                &fixture.wc_path,
8540                crate::Depth::Infinity,
8541                &filter,
8542                |path, cl| {
8543                    found.push((path.to_owned(), cl.map(str::to_owned)));
8544                    Ok(())
8545                },
8546            )
8547            .expect("get_changelists() should succeed");
8548
8549        assert_eq!(found.len(), 1, "expected exactly one changelist entry");
8550        assert_eq!(found[0].0, file_path.to_str().unwrap());
8551        assert_eq!(found[0].1, Some("my-cl".to_owned()));
8552
8553        // Remove from changelist (None means "clear").
8554        wc_ctx
8555            .set_changelist(&file_path, None, crate::Depth::Empty, &[])
8556            .expect("clearing changelist should succeed");
8557
8558        // After clearing, filtering for "my-cl" should yield no results.
8559        let mut after: Vec<(String, Option<String>)> = Vec::new();
8560        wc_ctx
8561            .get_changelists(
8562                &fixture.wc_path,
8563                crate::Depth::Infinity,
8564                &filter,
8565                |path, cl| {
8566                    after.push((path.to_owned(), cl.map(str::to_owned)));
8567                    Ok(())
8568                },
8569            )
8570            .expect("get_changelists() after clear should succeed");
8571
8572        assert_eq!(after.len(), 0, "expected no changelist entries after clear");
8573    }
8574
8575    #[test]
8576    fn test_get_changelists_empty_wc() {
8577        // With no filter, SVN visits every node and calls the callback even for
8578        // nodes with no changelist (cl == None).  In a clean WC no node has a
8579        // changelist, so we expect zero Some(...) entries.
8580        let fixture = SvnTestFixture::new();
8581
8582        let mut wc_ctx = Context::new().unwrap();
8583        let mut with_changelist = 0usize;
8584        wc_ctx
8585            .get_changelists(
8586                &fixture.wc_path,
8587                crate::Depth::Infinity,
8588                &[],
8589                |_path, cl| {
8590                    if cl.is_some() {
8591                        with_changelist += 1;
8592                    }
8593                    Ok(())
8594                },
8595            )
8596            .expect("get_changelists() on clean WC should succeed");
8597
8598        assert_eq!(
8599            with_changelist, 0,
8600            "expected zero nodes with a changelist in a fresh WC"
8601        );
8602    }
8603
8604    #[test]
8605    fn test_get_changelists_filter() {
8606        let mut fixture = SvnTestFixture::new();
8607        let file_a = fixture.add_file("a.txt", "a\n");
8608        let file_b = fixture.add_file("b.txt", "b\n");
8609        fixture.commit();
8610
8611        let mut wc_ctx = Context::new().unwrap();
8612
8613        wc_ctx
8614            .set_changelist(&file_a, Some("cl-alpha"), crate::Depth::Empty, &[])
8615            .expect("set cl-alpha on a.txt");
8616        wc_ctx
8617            .set_changelist(&file_b, Some("cl-beta"), crate::Depth::Empty, &[])
8618            .expect("set cl-beta on b.txt");
8619
8620        // Filter to retrieve only cl-alpha entries.  When a filter is given,
8621        // SVN only invokes the callback for matching nodes, so cl is always
8622        // Some(matching_name) here.
8623        let filter = vec!["cl-alpha".to_string()];
8624        let mut found: Vec<(String, Option<String>)> = Vec::new();
8625        wc_ctx
8626            .get_changelists(
8627                &fixture.wc_path,
8628                crate::Depth::Infinity,
8629                &filter,
8630                |path, cl| {
8631                    found.push((path.to_owned(), cl.map(str::to_owned)));
8632                    Ok(())
8633                },
8634            )
8635            .expect("get_changelists() with filter should succeed");
8636
8637        assert_eq!(found.len(), 1, "expected only the cl-alpha entry");
8638        assert_eq!(found[0].0, file_a.to_str().unwrap());
8639        assert_eq!(found[0].1, Some("cl-alpha".to_owned()));
8640    }
8641
8642    #[test]
8643    fn test_status_unmodified_file() {
8644        let mut fixture = SvnTestFixture::new();
8645        let file_path = fixture.add_file("hello.txt", "hello\n");
8646        fixture.commit();
8647
8648        let mut wc_ctx = Context::new().unwrap();
8649        let status = wc_ctx
8650            .status(&file_path)
8651            .expect("status() should succeed for a versioned file");
8652
8653        assert_eq!(status.node_status(), StatusKind::Normal);
8654        assert_eq!(status.text_status(), StatusKind::Normal);
8655    }
8656
8657    #[test]
8658    fn test_status_modified_file() {
8659        let mut fixture = SvnTestFixture::new();
8660        let file_path = fixture.add_file("hello.txt", "hello\n");
8661        fixture.commit();
8662
8663        // Modify the file after commit.
8664        std::fs::write(&file_path, "modified\n").unwrap();
8665
8666        let mut wc_ctx = Context::new().unwrap();
8667        let status = wc_ctx
8668            .status(&file_path)
8669            .expect("status() should succeed for a modified file");
8670
8671        assert_eq!(status.node_status(), StatusKind::Modified);
8672    }
8673
8674    #[test]
8675    fn test_status_dup() {
8676        let mut fixture = SvnTestFixture::new();
8677        let file_path = fixture.add_file("hello.txt", "hello\n");
8678        fixture.commit();
8679
8680        // Modify the file so we get an interesting status.
8681        std::fs::write(&file_path, "modified\n").unwrap();
8682
8683        let mut wc_ctx = Context::new().unwrap();
8684        let status = wc_ctx.status(&file_path).expect("status() should succeed");
8685
8686        let duped = status.dup();
8687
8688        assert_eq!(duped.node_status(), status.node_status());
8689        assert_eq!(duped.text_status(), status.text_status());
8690        assert_eq!(duped.copied(), status.copied());
8691        assert_eq!(duped.switched(), status.switched());
8692        assert_eq!(duped.locked(), status.locked());
8693        assert_eq!(duped.versioned(), status.versioned());
8694        assert_eq!(duped.revision(), status.revision());
8695        assert_eq!(duped.changed_rev(), status.changed_rev());
8696        assert_eq!(duped.repos_relpath(), status.repos_relpath());
8697        assert_eq!(duped.repos_uuid(), status.repos_uuid());
8698        assert_eq!(duped.repos_root_url(), status.repos_root_url());
8699        assert_eq!(duped.kind(), status.kind());
8700        assert_eq!(duped.depth(), status.depth());
8701        assert_eq!(duped.filesize(), status.filesize());
8702    }
8703
8704    #[test]
8705    fn test_check_root_wc_root() {
8706        let fixture = SvnTestFixture::new();
8707
8708        let mut wc_ctx = Context::new().unwrap();
8709        let (is_wcroot, is_switched, kind) = wc_ctx
8710            .check_root(&fixture.wc_path)
8711            .expect("check_root() should succeed on the WC root");
8712
8713        assert!(is_wcroot, "WC root directory should be reported as wcroot");
8714        assert!(!is_switched, "fresh checkout should not be switched");
8715        assert_eq!(kind, crate::NodeKind::Dir);
8716    }
8717
8718    #[test]
8719    fn test_check_root_non_root_file() {
8720        let mut fixture = SvnTestFixture::new();
8721        let file_path = fixture.add_file("hello.txt", "hello\n");
8722        fixture.commit();
8723
8724        let mut wc_ctx = Context::new().unwrap();
8725        let (is_wcroot, _is_switched, kind) = wc_ctx
8726            .check_root(&file_path)
8727            .expect("check_root() should succeed on a versioned file");
8728
8729        assert!(!is_wcroot, "a file is not a wcroot");
8730        assert_eq!(kind, crate::NodeKind::File);
8731    }
8732
8733    #[test]
8734    fn test_restore_missing_file() {
8735        let mut fixture = SvnTestFixture::new();
8736        let file_path = fixture.add_file("hello.txt", "hello\n");
8737        fixture.commit();
8738
8739        // Delete the file on disk without telling SVN (simulate a missing file).
8740        std::fs::remove_file(&file_path).unwrap();
8741
8742        let mut wc_ctx = Context::new().unwrap();
8743        wc_ctx
8744            .restore(&file_path, false)
8745            .expect("restore() should succeed for a missing versioned file");
8746
8747        assert!(
8748            file_path.exists(),
8749            "file should be restored on disk after restore()"
8750        );
8751        assert_eq!(
8752            std::fs::read_to_string(&file_path).unwrap(),
8753            "hello\n",
8754            "restored file should have original content"
8755        );
8756    }
8757
8758    #[test]
8759    fn test_get_ignores_returns_patterns() {
8760        let fixture = SvnTestFixture::new();
8761
8762        let mut wc_ctx = Context::new().unwrap();
8763        let patterns = wc_ctx
8764            .get_ignores(&fixture.wc_path)
8765            .expect("get_ignores() should succeed on a valid WC directory");
8766
8767        // SVN always includes a set of default global ignore patterns even
8768        // with no config file.  The list must be non-empty.
8769        assert!(
8770            !patterns.is_empty(),
8771            "expected at least some default ignore patterns"
8772        );
8773        // The default set always contains "*.o" (compiled objects).
8774        assert!(
8775            patterns.iter().any(|p| p == "*.o"),
8776            "expected '*.o' in default ignore patterns, got: {:?}",
8777            patterns
8778        );
8779    }
8780
8781    #[test]
8782    fn test_get_ignores_includes_svn_ignore_property() {
8783        let mut fixture = SvnTestFixture::new();
8784        fixture.commit();
8785
8786        // Set svn:ignore via the client context, which acquires write locks.
8787        let wc_path_str = fixture.wc_path_str().to_owned();
8788        fixture
8789            .client_ctx
8790            .propset(
8791                "svn:ignore",
8792                Some(b"my-custom-pattern\n"),
8793                &wc_path_str,
8794                &crate::client::PropSetOptions::default(),
8795            )
8796            .expect("propset svn:ignore should succeed");
8797
8798        let mut wc_ctx = Context::new().unwrap();
8799        let patterns = wc_ctx
8800            .get_ignores(&fixture.wc_path)
8801            .expect("get_ignores() should succeed");
8802
8803        assert!(
8804            patterns.iter().any(|p| p == "my-custom-pattern"),
8805            "expected custom pattern in ignore list, got: {:?}",
8806            patterns
8807        );
8808    }
8809
8810    #[test]
8811    fn test_canonicalize_svn_prop_executable() {
8812        // svn:executable is canonicalized to "*" regardless of input value.
8813        let result = canonicalize_svn_prop(
8814            "svn:executable",
8815            b"yes",
8816            "/some/path",
8817            crate::NodeKind::File,
8818        )
8819        .expect("canonicalize should succeed for svn:executable");
8820        assert_eq!(result, b"*");
8821    }
8822
8823    #[test]
8824    fn test_canonicalize_svn_prop_ignore_adds_newline() {
8825        // svn:ignore values without a trailing newline should get one added.
8826        let result = canonicalize_svn_prop("svn:ignore", b"*.o", "/some/dir", crate::NodeKind::Dir)
8827            .expect("canonicalize should succeed for svn:ignore");
8828        assert_eq!(result, b"*.o\n");
8829    }
8830
8831    #[test]
8832    fn test_canonicalize_svn_prop_keywords_strips_whitespace() {
8833        let result = canonicalize_svn_prop(
8834            "svn:keywords",
8835            b"  Rev Author  ",
8836            "/some/path",
8837            crate::NodeKind::File,
8838        )
8839        .expect("canonicalize should succeed for svn:keywords");
8840        assert_eq!(result, b"Rev Author");
8841    }
8842
8843    #[test]
8844    fn test_canonicalize_svn_prop_invalid_prop_errors() {
8845        // A property that is not valid for a file node should return an error.
8846        let result = canonicalize_svn_prop(
8847            "svn:ignore",
8848            b"*.o\n",
8849            "/some/path",
8850            crate::NodeKind::File, // svn:ignore is only valid on dirs
8851        );
8852        assert!(
8853            result.is_err(),
8854            "svn:ignore on a file should produce a validation error"
8855        );
8856    }
8857
8858    #[test]
8859    fn test_get_default_ignores_returns_patterns() {
8860        // get_default_ignores() returns global ignore patterns without a WC.
8861        let patterns = get_default_ignores().expect("get_default_ignores() should succeed");
8862
8863        // SVN always includes a set of default global ignore patterns even
8864        // with no config file.  The list must be non-empty.
8865        assert!(
8866            !patterns.is_empty(),
8867            "expected at least some default ignore patterns"
8868        );
8869        // The default set always contains "*.o" (compiled objects).
8870        assert!(
8871            patterns.iter().any(|p| p == "*.o"),
8872            "expected '*.o' in default ignore patterns, got: {:?}",
8873            patterns
8874        );
8875    }
8876
8877    #[test]
8878    fn test_get_default_ignores_does_not_include_svn_ignore() {
8879        // get_default_ignores() must not include svn:ignore property values
8880        // from any working copy directory — it only returns global patterns.
8881        let mut fixture = SvnTestFixture::new();
8882        fixture.commit();
8883
8884        // Set a directory-level svn:ignore property.
8885        let wc_path_str = fixture.wc_path_str().to_owned();
8886        fixture
8887            .client_ctx
8888            .propset(
8889                "svn:ignore",
8890                Some(b"my-custom-pattern\n"),
8891                &wc_path_str,
8892                &crate::client::PropSetOptions::default(),
8893            )
8894            .expect("propset svn:ignore should succeed");
8895
8896        let patterns = get_default_ignores().expect("get_default_ignores() should succeed");
8897
8898        assert!(
8899            !patterns.iter().any(|p| p == "my-custom-pattern"),
8900            "get_default_ignores() must not include svn:ignore property values"
8901        );
8902    }
8903
8904    #[test]
8905    fn test_remove_from_revision_control_requires_write_lock() {
8906        // svn_wc_remove_from_revision_control2 requires a write lock on the
8907        // parent directory.  Verify that remove_from_revision_control() propagates
8908        // the expected lock error rather than returning Ok(()) silently.
8909        let mut fixture = SvnTestFixture::new();
8910        let file_path = fixture.add_file("versioned.txt", "content\n");
8911        fixture.commit();
8912
8913        let mut wc_ctx = Context::new().unwrap();
8914        let result = wc_ctx.remove_from_revision_control(&file_path, false, false);
8915
8916        assert!(
8917            result.is_err(),
8918            "remove_from_revision_control() must fail without a write lock"
8919        );
8920        let err_msg = format!("{:?}", result.unwrap_err());
8921        assert!(
8922            err_msg.to_lowercase().contains("lock") || err_msg.to_lowercase().contains("write"),
8923            "expected a write-lock error, got: {}",
8924            err_msg
8925        );
8926    }
8927
8928    #[test]
8929    fn test_lock_new_with_path_and_token() {
8930        let lock = Lock::new(Some("/trunk/file.txt"), Some(b"opaquelocktoken:abc123"));
8931
8932        assert_eq!(lock.path(), Some("/trunk/file.txt"));
8933        assert_eq!(lock.token(), Some("opaquelocktoken:abc123"));
8934    }
8935
8936    #[test]
8937    fn test_lock_new_with_none() {
8938        let lock = Lock::new(None, None);
8939
8940        assert_eq!(lock.path(), None);
8941        assert_eq!(lock.token(), None);
8942        assert_eq!(lock.owner(), None);
8943        assert_eq!(lock.comment(), None);
8944    }
8945
8946    #[test]
8947    fn test_lock_new_with_path_only() {
8948        let lock = Lock::new(Some("/trunk/file.txt"), None);
8949
8950        assert_eq!(lock.path(), Some("/trunk/file.txt"));
8951        assert_eq!(lock.token(), None);
8952    }
8953
8954    #[test]
8955    #[allow(deprecated)]
8956    fn test_adm_relocate_without_validator() {
8957        let fixture = SvnTestFixture::new();
8958        let old_url = fixture.url.clone();
8959
8960        // Create a second repository to relocate to
8961        let (_repos_path2, new_url) = create_repo(fixture.temp_dir.path(), "repos2");
8962
8963        // Open the working copy with a write lock
8964        let adm = Adm::open(fixture.wc_path_str(), true, -1).unwrap();
8965
8966        // Relocate without a validator (this previously passed a null function pointer)
8967        let result = adm.relocate(fixture.wc_path_str(), &old_url, &new_url, true, None);
8968        assert!(
8969            result.is_ok(),
8970            "Adm::relocate() with no validator should succeed: {:?}",
8971            result.err()
8972        );
8973    }
8974
8975    #[test]
8976    #[allow(deprecated)]
8977    fn test_adm_relocate_with_validator() {
8978        let fixture = SvnTestFixture::new();
8979        let old_url = fixture.url.clone();
8980
8981        // Create a second repository to relocate to
8982        let (_repos_path2, new_url) = create_repo(fixture.temp_dir.path(), "repos2");
8983
8984        // Open the working copy with a write lock
8985        let adm = Adm::open(fixture.wc_path_str(), true, -1).unwrap();
8986
8987        // Relocate with a validator that accepts everything
8988        let validator = |_uuid: &str,
8989                         _url: &str,
8990                         _root_url: &str|
8991         -> Result<(), crate::Error<'static>> { Ok(()) };
8992        let result = adm.relocate(
8993            fixture.wc_path_str(),
8994            &old_url,
8995            &new_url,
8996            true,
8997            Some(&validator),
8998        );
8999        assert!(
9000            result.is_ok(),
9001            "Adm::relocate() with validator should succeed: {:?}",
9002            result.err()
9003        );
9004    }
9005
9006    #[test]
9007    #[allow(deprecated)]
9008    fn test_adm_close() {
9009        let fixture = SvnTestFixture::new();
9010        let mut adm = Adm::open(fixture.wc_path_str(), true, -1).unwrap();
9011        adm.close();
9012        // Drop after close should not panic
9013    }
9014
9015    #[test]
9016    #[allow(deprecated)]
9017    fn test_adm_close_idempotent() {
9018        let fixture = SvnTestFixture::new();
9019        let mut adm = Adm::open(fixture.wc_path_str(), true, -1).unwrap();
9020        adm.close();
9021        adm.close(); // should not panic
9022    }
9023
9024    #[test]
9025    #[allow(deprecated)]
9026    fn test_adm_queue_committed() {
9027        let mut fixture = SvnTestFixture::new();
9028        fixture.add_file("test.txt", "hello");
9029        fixture.commit();
9030
9031        let adm = Adm::open(fixture.wc_path_str(), true, -1).unwrap();
9032        let mut queue = CommittedQueue::new();
9033
9034        let file_path = fixture.wc_path.join("test.txt");
9035        let result = adm.queue_committed(
9036            file_path.to_str().unwrap(),
9037            &mut queue,
9038            false,
9039            false,
9040            false,
9041            None,
9042        );
9043        assert!(result.is_ok(), "queue_committed failed: {:?}", result.err());
9044    }
9045
9046    #[test]
9047    #[allow(deprecated)]
9048    fn test_adm_queue_committed_with_digest() {
9049        let mut fixture = SvnTestFixture::new();
9050        fixture.add_file("test.txt", "hello");
9051        fixture.commit();
9052
9053        let adm = Adm::open(fixture.wc_path_str(), true, -1).unwrap();
9054        let mut queue = CommittedQueue::new();
9055        let digest: [u8; 16] = [0; 16];
9056
9057        let file_path = fixture.wc_path.join("test.txt");
9058        let result = adm.queue_committed(
9059            file_path.to_str().unwrap(),
9060            &mut queue,
9061            false,
9062            false,
9063            false,
9064            Some(&digest),
9065        );
9066        assert!(
9067            result.is_ok(),
9068            "queue_committed with digest failed: {:?}",
9069            result.err()
9070        );
9071    }
9072
9073    #[test]
9074    #[allow(deprecated)]
9075    fn test_adm_process_committed_queue() {
9076        let mut fixture = SvnTestFixture::new();
9077        fixture.add_file("test.txt", "hello");
9078        fixture.commit();
9079
9080        let adm = Adm::open(fixture.wc_path_str(), true, -1).unwrap();
9081        let mut queue = CommittedQueue::new();
9082
9083        let file_path = fixture.wc_path.join("test.txt");
9084        adm.queue_committed(
9085            file_path.to_str().unwrap(),
9086            &mut queue,
9087            false,
9088            false,
9089            false,
9090            None,
9091        )
9092        .unwrap();
9093
9094        let result = adm.process_committed_queue(
9095            &mut queue,
9096            crate::Revnum(2),
9097            Some("2026-03-24T00:00:00.000000Z"),
9098            Some("testuser"),
9099        );
9100        assert!(
9101            result.is_ok(),
9102            "process_committed_queue failed: {:?}",
9103            result.err()
9104        );
9105    }
9106
9107    #[test]
9108    #[allow(deprecated)]
9109    fn test_adm_process_committed_queue_no_date_or_author() {
9110        let mut fixture = SvnTestFixture::new();
9111        fixture.add_file("test.txt", "hello");
9112        fixture.commit();
9113
9114        let adm = Adm::open(fixture.wc_path_str(), true, -1).unwrap();
9115        let mut queue = CommittedQueue::new();
9116
9117        let file_path = fixture.wc_path.join("test.txt");
9118        adm.queue_committed(
9119            file_path.to_str().unwrap(),
9120            &mut queue,
9121            false,
9122            false,
9123            false,
9124            None,
9125        )
9126        .unwrap();
9127
9128        let result = adm.process_committed_queue(&mut queue, crate::Revnum(2), None, None);
9129        assert!(
9130            result.is_ok(),
9131            "process_committed_queue with no date/author failed: {:?}",
9132            result.err()
9133        );
9134    }
9135}