Skip to main content

commonmeta/cmd/
put.rs

1use clap::{Arg, ArgAction, ArgMatches, Command};
2
3use crate::Data;
4
5use crate::cmd::convert::detect_format;
6
7pub fn command() -> Command {
8    Command::new("put")
9        .about("Put scholarly metadata into a service")
10        .long_about(
11            "Convert scholarly metadata between formats and register a single record\n\
12            with a service. Registration is currently only supported with InvenioRDM.\n\n\
13            The input is a file path, DOI, or URL. When --from is omitted the format\n\
14            is auto-detected: DOIs are resolved via the DOI RA API; JSON files are\n\
15            inspected for schema markers.\n\n\
16            This performs a real, network-visible write: a live record is created or\n\
17            updated and published on --host using --token for authentication.\n\n\
18            Examples:\n\n\
19            commonmeta put 10.5555/12345678 --from crossref --to inveniordm --host rogue-scholar.org --token TOKEN\n\
20            commonmeta put record.json --from commonmeta --to inveniordm --host my.invenio.host --token TOKEN",
21        )
22        .arg(
23            Arg::new("input")
24                .help("File path, DOI, or URL")
25                .required(true)
26                .index(1),
27        )
28        .arg(
29            Arg::new("from")
30                .long("from")
31                .short('f')
32                .help("Input format; auto-detected if omitted"),
33        )
34        .arg(
35            Arg::new("to")
36                .long("to")
37                .short('t')
38                .help("Target service to register with")
39                .default_value("inveniordm"),
40        )
41        .arg(Arg::new("doi").long("doi").help("DOI to assign"))
42        .arg(Arg::new("prefix").long("prefix").help("DOI prefix"))
43        .arg(
44            Arg::new("depositor")
45                .long("depositor")
46                .help("Depositor name (used for Crossref XML registration)"),
47        )
48        .arg(
49            Arg::new("email")
50                .long("email")
51                .help("Depositor email (used for Crossref XML registration)"),
52        )
53        .arg(
54            Arg::new("registrant")
55                .long("registrant")
56                .help("Registrant name (used for Crossref XML registration)"),
57        )
58        .arg(
59            Arg::new("login-id")
60                .long("login-id")
61                .help("Login ID for Crossref XML deposit"),
62        )
63        .arg(
64            Arg::new("login-passwd")
65                .long("login-passwd")
66                .help("Login password for Crossref XML deposit"),
67        )
68        .arg(
69            Arg::new("test-mode")
70                .long("test-mode")
71                .help("Use test mode for Crossref XML deposit")
72                .action(ArgAction::SetTrue),
73        )
74        .arg(
75            Arg::new("legacy-conn")
76                .long("legacy-conn")
77                .help("Legacy connection string"),
78        )
79        .arg(
80            Arg::new("host")
81                .long("host")
82                .help("InvenioRDM host (e.g. rogue-scholar.org)"),
83        )
84        .arg(Arg::new("token").long("token").help("InvenioRDM API token"))
85        .arg(
86            Arg::new("show-errors")
87                .long("show-errors")
88                .help("Print validation errors")
89                .action(ArgAction::SetTrue),
90        )
91}
92
93pub fn execute(matches: &ArgMatches) -> Result<(), String> {
94    let input_arg = matches.get_one::<String>("input").expect("required");
95    let to = matches
96        .get_one::<String>("to")
97        .map(String::as_str)
98        .unwrap_or("inveniordm");
99
100    let input = if std::path::Path::new(input_arg).exists() {
101        std::fs::read_to_string(input_arg)
102            .map_err(|e| format!("failed to read '{}': {}", input_arg, e))?
103    } else {
104        input_arg.clone()
105    };
106
107    let via = match matches.get_one::<String>("from") {
108        Some(f) => f.clone(),
109        None => detect_format(&input, false),
110    };
111
112    let data = crate::read(&via, &input).map_err(|e| e.to_string())?;
113
114    match to {
115        "inveniordm" => put_to_inveniordm(&data, matches),
116        "crossref_xml" | "datacite" => Err(format!(
117            "put: --to {} is not yet implemented (registration is currently only supported with --to inveniordm)",
118            to
119        )),
120        other => Err(format!("put: unsupported --to target: {}", other)),
121    }
122}
123
124fn put_to_inveniordm(data: &Data, matches: &ArgMatches) -> Result<(), String> {
125    let host = matches
126        .get_one::<String>("host")
127        .map(String::as_str)
128        .filter(|s| !s.is_empty())
129        .ok_or_else(|| "put: --to inveniordm requires --host <host>".to_string())?;
130    let token = matches
131        .get_one::<String>("token")
132        .map(String::as_str)
133        .filter(|s| !s.is_empty())
134        .ok_or_else(|| "put: --to inveniordm requires --token <token>".to_string())?;
135
136    let result = crate::put_inveniordm(data, host, token);
137    let output = serde_json::to_string_pretty(&result).map_err(|e| e.to_string())?;
138    println!("{}", output);
139    Ok(())
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_execute_requires_host_for_inveniordm() {
148        let dir = std::env::temp_dir().join("commonmeta_put_test_host");
149        std::fs::create_dir_all(&dir).unwrap();
150        let path = dir.join("record.json");
151        std::fs::write(
152            &path,
153            r#"{"id":"https://doi.org/10.1/a","type":"JournalArticle","schema_version":"https://commonmeta.org/commonmeta_v1.0.json"}"#,
154        )
155        .unwrap();
156
157        let matches =
158            command().get_matches_from(vec!["put", path.to_str().unwrap(), "--from", "commonmeta"]);
159        let err = execute(&matches).unwrap_err();
160        assert!(err.contains("requires --host"));
161
162        std::fs::remove_dir_all(&dir).ok();
163    }
164
165    #[test]
166    fn test_execute_rejects_unimplemented_to() {
167        let dir = std::env::temp_dir().join("commonmeta_put_test_to");
168        std::fs::create_dir_all(&dir).unwrap();
169        let path = dir.join("record.json");
170        std::fs::write(
171            &path,
172            r#"{"id":"https://doi.org/10.1/a","type":"JournalArticle","schema_version":"https://commonmeta.org/commonmeta_v1.0.json"}"#,
173        )
174        .unwrap();
175
176        let matches = command().get_matches_from(vec![
177            "put",
178            path.to_str().unwrap(),
179            "--from",
180            "commonmeta",
181            "--to",
182            "crossref_xml",
183        ]);
184        let err = execute(&matches).unwrap_err();
185        assert!(err.contains("not yet implemented"));
186
187        std::fs::remove_dir_all(&dir).ok();
188    }
189
190    #[test]
191    fn test_execute_rejects_missing_token() {
192        let dir = std::env::temp_dir().join("commonmeta_put_test_token");
193        std::fs::create_dir_all(&dir).unwrap();
194        let path = dir.join("record.json");
195        std::fs::write(
196            &path,
197            r#"{"id":"https://doi.org/10.1/a","type":"JournalArticle","schema_version":"https://commonmeta.org/commonmeta_v1.0.json"}"#,
198        )
199        .unwrap();
200
201        let matches = command().get_matches_from(vec![
202            "put",
203            path.to_str().unwrap(),
204            "--from",
205            "commonmeta",
206            "--host",
207            "example.invenio.host",
208        ]);
209        let err = execute(&matches).unwrap_err();
210        assert!(err.contains("requires --token"));
211
212        std::fs::remove_dir_all(&dir).ok();
213    }
214
215    #[test]
216    fn test_execute_file_not_found_reads_as_literal_input() {
217        // A nonexistent path is treated as a literal identifier string (e.g.
218        // a DOI), not a file-read error, matching convert.rs's behavior.
219        let matches = command().get_matches_from(vec![
220            "put",
221            "not-a-real-path-or-doi",
222            "--from",
223            "commonmeta",
224        ]);
225        // crate::read with via="commonmeta" will fail to parse the
226        // literal string as JSON, surfacing a parse error rather than a
227        // missing-host error — confirms we got past the file-read branch.
228        let err = execute(&matches).unwrap_err();
229        assert!(!err.contains("failed to read"));
230    }
231}