1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
use miette::Diagnostic;

use crate::ci::Error as CiError;
use quire_core::fennel::FennelError;
use quire_core::secret;

#[derive(Debug, thiserror::Error, Diagnostic)]
pub enum Error {
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),

    #[error(transparent)]
    Repo(#[from] RepoNameError),

    #[error("repository not found: {0}")]
    RepoNotFound(String),

    #[error(transparent)]
    #[diagnostic(transparent)]
    Fennel(#[from] Box<FennelError>),

    #[error(transparent)]
    #[diagnostic(transparent)]
    Ci(#[from] CiError),

    #[error(transparent)]
    Secret(#[from] secret::Error),

    #[error(transparent)]
    Sql(#[from] rusqlite::Error),

    #[error(transparent)]
    Yaml(#[from] serde_yaml_ng::Error),

    #[error(transparent)]
    Utf8(#[from] std::string::FromUtf8Error),
}

pub type Result<T> = std::result::Result<T, Error>;

#[derive(Debug, thiserror::Error, Diagnostic)]
pub enum RepoNameError {
    #[error("repository name cannot be empty")]
    Empty,
    #[error("repository name must end in .git: {0}")]
    MissingGitSuffix(String),
    #[error("repository name allows at most one level of grouping: {0}")]
    TooManySegments(String),
    #[error("path is not under repos directory: {0}")]
    PathOutsideBase(String),
    #[error("invalid repository name: {0}")]
    Invalid(String),
}

impl From<FennelError> for Error {
    fn from(err: FennelError) -> Self {
        Error::Fennel(Box::new(err))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use quire_core::fennel::FennelError;

    #[test]
    fn from_fennel_error() {
        let fennel_err = FennelError::Io(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            "test.fnl",
        ));
        let err: Error = fennel_err.into();
        assert!(err.to_string().contains("test.fnl"));
    }

    #[test]
    fn fennel_eval_display_is_self_contained() {
        // FennelError::Eval's Display should carry both the source
        // name and the underlying lua error text — without needing to
        // walk the `#[source]` chain — so plain `%err` is enough for
        // tracing/Sentry. The chain is still preserved structurally.
        let f = quire_core::fennel::Fennel::new().expect("Fennel::new");
        let result: std::result::Result<i32, _> =
            f.load_string("(this is not valid", "bad.fnl", |_| {});
        let fennel_err = result.unwrap_err();

        let plain = fennel_err.to_string();
        assert!(
            plain.starts_with("bad.fnl: "),
            "Display should start with the source name: {plain:?}"
        );
        assert!(
            plain.len() > "bad.fnl: ".len(),
            "Display should include the underlying error detail: {plain:?}"
        );

        // The `#[source]` chain is still walkable.
        let err: &dyn std::error::Error = &fennel_err;
        assert!(err.source().is_some(), "source chain should be preserved");
    }

    #[test]
    fn from_pipeline_error() {
        let source = "(ci.job :a [] (fn [_] nil))";
        let pipeline_err = crate::ci::PipelineError {
            src: miette::NamedSource::new("ci.fnl", source.to_string()),
            diagnostics: vec![crate::ci::Diagnostic::Definition(
                crate::ci::DefinitionError::EmptyInputs {
                    job_id: "a".to_string(),
                    span: miette::SourceSpan::from((0, 0)),
                },
            )],
        };
        let ci_err = crate::ci::Error::from(pipeline_err);
        let err: Error = ci_err.into();
        assert!(err.to_string().contains("ci.fnl has errors"));
    }
}