Skip to main content

debian_workbench/
lib.rs

1//! Library for manipulating Debian packages.
2#![deny(missing_docs)]
3use breezyshim::branch::Branch;
4use breezyshim::dirty_tracker::DirtyTreeTracker;
5use breezyshim::error::Error;
6#[cfg(feature = "python")]
7use breezyshim::repository::PyRepository;
8use breezyshim::tree::{PyTree, Tree, TreeChange, WorkingTree};
9use breezyshim::workingtree::PyWorkingTree;
10use breezyshim::workspace::reset_tree_with_dirty_tracker;
11#[cfg(feature = "python")]
12use pyo3::prelude::*;
13
14pub mod abstract_control;
15pub mod changelog;
16pub mod config;
17pub mod control;
18pub mod debcargo;
19pub mod debcommit;
20pub mod debhelper;
21pub mod detect_gbp_dch;
22pub mod editor;
23pub mod lintian;
24pub mod maintscripts;
25pub mod patches;
26pub mod publish;
27pub mod relations;
28pub mod release_info;
29pub mod rules;
30pub mod vcs;
31pub mod vendor;
32pub mod versions;
33
34// TODO(jelmer): Import this from ognibuild
35/// Default builder
36pub const DEFAULT_BUILDER: &str = "sbuild --no-clean-source";
37
38#[derive(Debug)]
39/// Error applying a change
40pub enum ApplyError<R, E> {
41    /// Error from the callback
42    CallbackError(E),
43    /// Error from the tree
44    BrzError(Error),
45    /// No changes made
46    NoChanges(R),
47}
48
49impl<R, E> From<Error> for ApplyError<R, E> {
50    fn from(e: Error) -> Self {
51        ApplyError::BrzError(e)
52    }
53}
54
55/// Apply a change in a clean tree.
56///
57/// This will either run a callback in a tree, or if the callback fails,
58/// revert the tree to the original state.
59///
60/// The original tree should be clean; you can run check_clean_tree() to
61/// verify this.
62///
63/// # Arguments
64/// * `local_tree` - Local tree
65/// * `subpath` - Subpath to apply changes to
66/// * `basis_tree` - Basis tree to reset to
67/// * `dirty_tracker` - Dirty tracker
68/// * `applier` - Callback to apply changes
69///
70/// # Returns
71/// * `Result<(R, Vec<TreeChange>, Option<Vec<std::path::PathBuf>>), E>` - Result of the callback,
72///   the changes made, and the files that were changed
73pub fn apply_or_revert<R, E, T, U>(
74    local_tree: &T,
75    subpath: &std::path::Path,
76    basis_tree: &U,
77    dirty_tracker: Option<&mut DirtyTreeTracker>,
78    applier: impl FnOnce(&std::path::Path) -> Result<R, E>,
79) -> Result<(R, Vec<TreeChange>, Option<Vec<std::path::PathBuf>>), ApplyError<R, E>>
80where
81    T: PyWorkingTree + breezyshim::tree::PyMutableTree,
82    U: PyTree,
83{
84    let r = match applier(local_tree.abspath(subpath).unwrap().as_path()) {
85        Ok(r) => r,
86        Err(e) => {
87            reset_tree_with_dirty_tracker(
88                local_tree,
89                Some(basis_tree),
90                Some(subpath),
91                dirty_tracker,
92            )
93            .unwrap();
94            return Err(ApplyError::CallbackError(e));
95        }
96    };
97
98    let specific_files = if let Some(relpaths) = dirty_tracker.and_then(|x| x.relpaths()) {
99        let mut relpaths: Vec<_> = relpaths.into_iter().collect();
100        relpaths.sort();
101        // Sort paths so that directories get added before the files they
102        // contain (on VCSes where it matters)
103        local_tree.add(
104            relpaths
105                .iter()
106                .filter_map(|p| {
107                    if local_tree.has_filename(p) && local_tree.is_ignored(p).is_some() {
108                        Some(p.as_path())
109                    } else {
110                        None
111                    }
112                })
113                .collect::<Vec<_>>()
114                .as_slice(),
115        )?;
116        let specific_files = relpaths
117            .into_iter()
118            .filter(|p| local_tree.is_versioned(p))
119            .collect::<Vec<_>>();
120        if specific_files.is_empty() {
121            return Err(ApplyError::NoChanges(r));
122        }
123        Some(specific_files)
124    } else {
125        local_tree.smart_add(&[local_tree.abspath(subpath).unwrap().as_path()])?;
126        if subpath.as_os_str().is_empty() {
127            None
128        } else {
129            Some(vec![subpath.to_path_buf()])
130        }
131    };
132
133    if local_tree.supports_setting_file_ids() {
134        let local_lock = local_tree.lock_read().unwrap();
135        let basis_lock = basis_tree.lock_read().unwrap();
136        breezyshim::rename_map::guess_renames(basis_tree, local_tree).unwrap();
137        std::mem::drop(basis_lock);
138        std::mem::drop(local_lock);
139    }
140
141    let specific_files_ref = specific_files
142        .as_ref()
143        .map(|fs| fs.iter().map(|p| p.as_path()).collect::<Vec<_>>());
144
145    let changes = local_tree
146        .iter_changes(
147            basis_tree,
148            specific_files_ref.as_deref(),
149            Some(false),
150            Some(true),
151        )?
152        .collect::<Result<Vec<_>, _>>()?;
153
154    if local_tree.get_parent_ids()?.len() <= 1 && changes.is_empty() {
155        return Err(ApplyError::NoChanges(r));
156    }
157
158    Ok((r, changes, specific_files))
159}
160
161/// A changelog error
162pub enum ChangelogError {
163    /// Not a Debian package
164    NotDebianPackage(std::path::PathBuf),
165    #[cfg(feature = "python")]
166    /// Python error
167    Python(pyo3::PyErr),
168}
169
170impl std::fmt::Display for ChangelogError {
171    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
172        match self {
173            ChangelogError::NotDebianPackage(path) => {
174                write!(f, "Not a Debian package: {}", path.display())
175            }
176            #[cfg(feature = "python")]
177            ChangelogError::Python(e) => write!(f, "{}", e),
178        }
179    }
180}
181
182#[cfg(feature = "python")]
183// This is needed because import_exception! checks the gil-refs
184// feature which we don't provide.
185#[allow(unexpected_cfgs)]
186impl From<pyo3::PyErr> for ChangelogError {
187    fn from(e: pyo3::PyErr) -> Self {
188        use pyo3::import_exception;
189
190        import_exception!(breezy.transport, NoSuchFile);
191
192        pyo3::Python::attach(|py| {
193            if e.is_instance_of::<NoSuchFile>(py) {
194                return ChangelogError::NotDebianPackage(
195                    e.into_value(py)
196                        .bind(py)
197                        .getattr("path")
198                        .unwrap()
199                        .extract()
200                        .unwrap(),
201                );
202            } else {
203                ChangelogError::Python(e)
204            }
205        })
206    }
207}
208
209/// Add an entry to a changelog.
210///
211/// # Arguments
212/// * `working_tree` - Working tree
213/// * `changelog_path` - Path to the changelog
214/// * `entry` - Changelog entry
215pub fn add_changelog_entry<T: WorkingTree>(
216    working_tree: &T,
217    changelog_path: &std::path::Path,
218    entry: &[&str],
219) -> Result<(), crate::editor::EditorError<debian_changelog::Error>> {
220    use crate::editor::{Editor, MutableTreeEdit};
221    let mut cl =
222        working_tree.edit_file::<debian_changelog::ChangeLog>(changelog_path, false, true)?;
223
224    cl.try_auto_add_change(
225        entry,
226        debian_changelog::get_maintainer().unwrap(),
227        Some(chrono::Utc::now().fixed_offset()),
228        None,
229    )
230    .unwrap();
231
232    cl.commit()?;
233
234    Ok(())
235}
236
237#[derive(
238    Clone,
239    Copy,
240    PartialEq,
241    Eq,
242    Debug,
243    Default,
244    PartialOrd,
245    Ord,
246    serde::Serialize,
247    serde::Deserialize,
248)]
249/// Certainty of a change.
250pub enum Certainty {
251    #[serde(rename = "possible")]
252    /// Possible change, basically a guess
253    Possible,
254    #[serde(rename = "likely")]
255    /// Likely to be correct, but not certain
256    Likely,
257    #[serde(rename = "confident")]
258    /// Confident change, but not absolutely certain
259    Confident,
260    #[default]
261    #[serde(rename = "certain")]
262    /// Absolutely certain change
263    Certain,
264}
265
266impl std::str::FromStr for Certainty {
267    type Err = String;
268
269    fn from_str(value: &str) -> Result<Self, Self::Err> {
270        match value {
271            "certain" => Ok(Certainty::Certain),
272            "confident" => Ok(Certainty::Confident),
273            "likely" => Ok(Certainty::Likely),
274            "possible" => Ok(Certainty::Possible),
275            _ => Err(format!("Invalid certainty: {}", value)),
276        }
277    }
278}
279
280impl std::fmt::Display for Certainty {
281    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282        match self {
283            Certainty::Certain => write!(f, "certain"),
284            Certainty::Confident => write!(f, "confident"),
285            Certainty::Likely => write!(f, "likely"),
286            Certainty::Possible => write!(f, "possible"),
287        }
288    }
289}
290
291#[cfg(feature = "python")]
292impl pyo3::FromPyObject<'_, '_> for Certainty {
293    type Error = pyo3::PyErr;
294
295    fn extract(ob: pyo3::Borrowed<'_, '_, pyo3::PyAny>) -> Result<Self, Self::Error> {
296        use std::str::FromStr;
297        let s = ob.extract::<String>()?;
298        Certainty::from_str(&s).map_err(pyo3::exceptions::PyValueError::new_err)
299    }
300}
301
302#[cfg(feature = "python")]
303impl<'py> pyo3::IntoPyObject<'py> for Certainty {
304    type Target = pyo3::types::PyString;
305
306    type Output = pyo3::Bound<'py, Self::Target>;
307
308    type Error = pyo3::PyErr;
309
310    fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
311        let s = self.to_string();
312        Ok(pyo3::types::PyString::new(py, &s))
313    }
314}
315
316/// Check if the actual certainty is sufficient.
317///
318/// # Arguments
319///
320/// * `actual_certainty` - Actual certainty with which changes were made
321/// * `minimum_certainty` - Minimum certainty to keep changes
322///
323/// # Returns
324///
325/// * `bool` - Whether the actual certainty is sufficient
326pub fn certainty_sufficient(
327    actual_certainty: Certainty,
328    minimum_certainty: Option<Certainty>,
329) -> bool {
330    if let Some(minimum_certainty) = minimum_certainty {
331        actual_certainty >= minimum_certainty
332    } else {
333        true
334    }
335}
336
337/// Return the minimum certainty from a list of certainties.
338pub fn min_certainty(certainties: &[Certainty]) -> Option<Certainty> {
339    certainties.iter().min().cloned()
340}
341
342#[cfg(feature = "python")]
343fn get_git_committer(working_tree: &dyn PyWorkingTree) -> Option<String> {
344    pyo3::Python::attach(|py| {
345        let repo = working_tree.branch().repository();
346        let git = match repo.to_object(py).getattr(py, "_git") {
347            Ok(x) => Some(x),
348            Err(e) if e.is_instance_of::<pyo3::exceptions::PyAttributeError>(py) => None,
349            Err(e) => {
350                return Err(e);
351            }
352        };
353
354        if let Some(git) = git {
355            let cs = git.call_method0(py, "get_config_stack")?;
356
357            let mut user = std::env::var("GIT_COMMITTER_NAME").ok();
358            let mut email = std::env::var("GIT_COMMITTER_EMAIL").ok();
359            if user.is_none() {
360                match cs.call_method1(py, "get", (("user",), "name")) {
361                    Ok(x) => {
362                        user = Some(
363                            std::str::from_utf8(x.extract::<&[u8]>(py)?)
364                                .unwrap()
365                                .to_string(),
366                        );
367                    }
368                    Err(e) if e.is_instance_of::<pyo3::exceptions::PyKeyError>(py) => {
369                        // Ignore
370                    }
371                    Err(e) => {
372                        return Err(e);
373                    }
374                };
375            }
376            if email.is_none() {
377                match cs.call_method1(py, "get", (("user",), "email")) {
378                    Ok(x) => {
379                        email = Some(
380                            std::str::from_utf8(x.extract::<&[u8]>(py)?)
381                                .unwrap()
382                                .to_string(),
383                        );
384                    }
385                    Err(e) if e.is_instance_of::<pyo3::exceptions::PyKeyError>(py) => {
386                        // Ignore
387                    }
388                    Err(e) => {
389                        return Err(e);
390                    }
391                };
392            }
393
394            if let (Some(user), Some(email)) = (user, email) {
395                return Ok(Some(format!("{} <{}>", user, email)));
396            }
397
398            let gs = breezyshim::config::global_stack().unwrap();
399
400            Ok(gs
401                .get("email")?
402                .map(|email| email.extract::<String>(py).unwrap()))
403        } else {
404            Ok(None)
405        }
406    })
407    .unwrap()
408}
409
410/// Get the committer string for a tree
411pub fn get_committer(working_tree: &dyn PyWorkingTree) -> String {
412    #[cfg(feature = "python")]
413    if let Some(committer) = get_git_committer(working_tree) {
414        return committer;
415    }
416
417    let config = working_tree.branch().get_config_stack();
418
419    config
420        .get("email")
421        .unwrap()
422        .map(|x| x.to_string())
423        .unwrap_or_default()
424}
425
426/// Check whether there are any control files present in a tree.
427///
428/// # Arguments
429///
430///   * `tree`: tree to check
431///   * `subpath`: subpath to check
432///
433/// # Returns
434///
435/// whether control file is present
436pub fn control_file_present(tree: &dyn Tree, subpath: &std::path::Path) -> bool {
437    for name in [
438        "debian/control",
439        "debian/control.in",
440        "control",
441        "control.in",
442        "debian/debcargo.toml",
443    ] {
444        let name = subpath.join(name);
445        if tree.has_filename(name.as_path()) {
446            return true;
447        }
448    }
449    false
450}
451
452/// Check whether the package in a tree uses debcargo.
453pub fn is_debcargo_package(tree: &dyn Tree, subpath: &std::path::Path) -> bool {
454    tree.has_filename(subpath.join("debian/debcargo.toml").as_path())
455}
456
457/// Check whether the package in a tree has control files in the root, rather than in debian/.
458pub fn control_files_in_root(tree: &dyn Tree, subpath: &std::path::Path) -> bool {
459    let debian_path = subpath.join("debian");
460    if tree.has_filename(debian_path.as_path()) {
461        return false;
462    }
463
464    let control_path = subpath.join("control");
465    if tree.has_filename(control_path.as_path()) {
466        return true;
467    }
468
469    tree.has_filename(subpath.join("control.in").as_path())
470}
471
472/// Parse a Debian name and email address from a string.
473pub fn parseaddr(input: &str) -> Option<(Option<String>, Option<String>)> {
474    if let Some((_whole, name, addr)) =
475        lazy_regex::regex_captures!(r"(?:(?P<name>[^<]*)\s*<)?(?P<addr>[^<>]*)>?", input)
476    {
477        let name = match name.trim() {
478            "" => None,
479            x => Some(x.to_string()),
480        };
481        let addr = match addr.trim() {
482            "" => None,
483            x => Some(x.to_string()),
484        };
485
486        return Some((name, addr));
487    } else if let Some((_whole, addr)) = lazy_regex::regex_captures!(r"(?P<addr>[^<>]*)", input) {
488        let addr = Some(addr.trim().to_string());
489
490        return Some((None, addr));
491    } else if input.is_empty() {
492        return None;
493    } else if !input.contains('<') {
494        return Some((None, Some(input.to_string())));
495    }
496    None
497}
498
499/// Run gbp dch
500pub fn gbp_dch(path: &std::path::Path) -> Result<(), std::io::Error> {
501    let mut cmd = std::process::Command::new("gbp");
502    cmd.arg("dch").arg("--ignore-branch");
503    cmd.current_dir(path);
504    let status = cmd.status()?;
505    if !status.success() {
506        return Err(std::io::Error::other(format!("gbp dch failed: {}", status)));
507    }
508    Ok(())
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use serial_test::serial;
515
516    #[test]
517    fn test_parseaddr() {
518        assert_eq!(
519            parseaddr("foo <bar@example.com>").unwrap(),
520            (Some("foo".to_string()), Some("bar@example.com".to_string()))
521        );
522        assert_eq!(parseaddr("foo").unwrap(), (None, Some("foo".to_string())));
523    }
524
525    #[cfg(feature = "python")]
526    #[serial]
527    #[test]
528    fn test_git_env() {
529        let td = tempfile::tempdir().unwrap();
530        let cd = breezyshim::controldir::create_standalone_workingtree(td.path(), "git").unwrap();
531
532        let old_name = std::env::var("GIT_COMMITTER_NAME").ok();
533        let old_email = std::env::var("GIT_COMMITTER_EMAIL").ok();
534
535        std::env::set_var("GIT_COMMITTER_NAME", "Some Git Committer");
536        std::env::set_var("GIT_COMMITTER_EMAIL", "committer@example.com");
537
538        let committer = get_committer(&cd);
539
540        if let Some(old_name) = old_name {
541            std::env::set_var("GIT_COMMITTER_NAME", old_name);
542        } else {
543            std::env::remove_var("GIT_COMMITTER_NAME");
544        }
545
546        if let Some(old_email) = old_email {
547            std::env::set_var("GIT_COMMITTER_EMAIL", old_email);
548        } else {
549            std::env::remove_var("GIT_COMMITTER_EMAIL");
550        }
551
552        assert_eq!("Some Git Committer <committer@example.com>", committer);
553    }
554
555    #[serial]
556    #[test]
557    fn test_git_config() {
558        let td = tempfile::tempdir().unwrap();
559        let cd = breezyshim::controldir::create_standalone_workingtree(td.path(), "git").unwrap();
560
561        std::fs::write(
562            td.path().join(".git/config"),
563            b"[user]\nname = Some Git Committer\nemail = other@example.com",
564        )
565        .unwrap();
566
567        assert_eq!(get_committer(&cd), "Some Git Committer <other@example.com>");
568    }
569
570    #[test]
571    fn test_min_certainty() {
572        assert_eq!(None, min_certainty(&[]));
573        assert_eq!(
574            Some(Certainty::Certain),
575            min_certainty(&[Certainty::Certain])
576        );
577        assert_eq!(
578            Some(Certainty::Possible),
579            min_certainty(&[Certainty::Possible])
580        );
581        assert_eq!(
582            Some(Certainty::Possible),
583            min_certainty(&[Certainty::Possible, Certainty::Certain])
584        );
585        assert_eq!(
586            Some(Certainty::Likely),
587            min_certainty(&[Certainty::Likely, Certainty::Certain])
588        );
589        assert_eq!(
590            Some(Certainty::Possible),
591            min_certainty(&[Certainty::Likely, Certainty::Certain, Certainty::Possible])
592        );
593    }
594}