Skip to main content

oseda_cli/cmd/
deploy.rs

1use std::{env, error::Error, fs, path::Path};
2
3use clap::Args;
4
5use crate::{
6    config,
7    github::{self, git},
8};
9
10/// Options for the `oseda deploy` command
11#[derive(Args, Debug)]
12pub struct DeployOptions {
13    fork_url: String,
14}
15
16struct SshUrl(String);
17
18/// string deref
19impl std::ops::Deref for SshUrl {
20    type Target = String;
21
22    fn deref(&self) -> &Self::Target {
23        &self.0
24    }
25}
26
27/// Convert a standard HTTPS GitHub URL to SSH format
28///
29/// # Arguments
30/// * `value` - a String starting with `https://github.com/...`
31///
32/// # Returns
33/// * `Ok(SshUrl)` if parsing succeeds
34/// * `Err` if the format is not recognized
35impl TryFrom<String> for SshUrl {
36    type Error = Box<dyn Error>;
37
38    fn try_from(value: String) -> Result<Self, Self::Error> {
39        // https://github.com/ReeseHatfield/oseda-lib-testing/
40        // into
41        // git@github.com:ReeseHatfield/oseda-lib-testing.git
42        let suffix = value
43            .strip_prefix("https://github.com/")
44            .ok_or("Could not get SSH URL")?;
45
46        Ok(SshUrl(format!(
47            "git@github.com:{}.git",
48            suffix.trim_end_matches('/')
49        )))
50    }
51}
52
53/// Deploys an Oseda project to the provided fork URL
54///
55/// # Arguments
56/// * `opts` - options with the `fork_url` for the deployment target
57///
58/// # Returns
59/// * `Ok(())` on success
60/// * `Err` if any git, file, or config step fails, including a check failure
61pub fn deploy(opts: DeployOptions) -> Result<(), Box<dyn Error>> {
62    let tmp_dir = tempfile::tempdir()?;
63    let repo_path = tmp_dir.path();
64
65    let ssh_url: SshUrl = opts.fork_url.try_into()?;
66
67    git(
68        repo_path,
69        &["clone", "--no-checkout", ssh_url.0.as_str(), "."],
70    )?;
71
72    println!("Running git with sparse checkout");
73    git(repo_path, &["sparse-checkout", "init", "--cone"])?;
74    git(repo_path, &["sparse-checkout", "set", "courses"])?;
75    git(repo_path, &["checkout"])?;
76
77    let course_name = get_current_dir_name()?;
78    let new_course_dir = repo_path.join("courses").join(&course_name);
79
80    copy_dir_all(env::current_dir()?, &new_course_dir)?;
81
82    // bails if config is bad
83    //
84    // force a no-skip-git
85    let conf = config::read_and_validate_config()?;
86
87    config::update_time(conf)?;
88    println!("Committing files to remote...");
89    git(repo_path, &["add", "."])?;
90    git(repo_path, &["commit", "-m", "Add new course"])?;
91    git(repo_path, &["push"])?;
92
93    println!("Project successfully pushed to remote.");
94
95    // https://github.com/oseda-dev/oseda-lib/compare/main...ReeseHatfield:oseda-lib:main?expand=1
96
97    match github::get_config_from_user_git("user.name") {
98        Some(github_username) => {
99            let pull_request_url = format!(
100                "https://github.com/oseda-dev/oseda-lib/compare/main...{}:oseda-lib:main?expand=1",
101                github_username
102            );
103
104            println!("Add your presentation to oseda.net by making a Pull Request at:");
105            println!();
106            println!("{}", pull_request_url);
107        }
108        None => {
109            println!("Error: could not get github username");
110            return Err("Deployment failed due to missing github credential. Pleas ensure user.name matches your github username".into());
111        }
112    }
113
114    Ok(())
115}
116
117/// Util fn to get the current working directory name
118///
119/// # Returns
120/// * `Ok(String)` with the directory name
121/// * `Err` if the name failed to be extracted
122fn get_current_dir_name() -> Result<String, Box<dyn Error>> {
123    // this is like really stupid to have this, since
124    // this logic is basically already used in `check`
125    // but really most of that logic should be moved to a config.rs file
126    // but until then, I am just reading the cwd with this
127    let cwd = env::current_dir()?;
128    let name = cwd
129        .file_name()
130        .ok_or("couldn't get directory name")?
131        .to_string_lossy()
132        .to_string();
133    Ok(name)
134}
135
136/// Recursively copy a directory
137/// https://stackoverflow.com/questions/26958489/how-to-copy-a-folder-recursively-in-rust
138fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<(), Box<dyn Error>> {
139    let src = src.as_ref();
140    let dst = dst.as_ref();
141
142    fs::create_dir_all(dst)?;
143
144    for entry in fs::read_dir(src)? {
145        let entry = entry?;
146        let entry_path = entry.path();
147
148        // skip `.git` directory
149        if entry_path.ends_with(".git") {
150            continue;
151        }
152
153        let ty = entry.file_type()?;
154
155        if ty.is_dir() {
156            copy_dir_all(&entry_path, dst.join(entry.file_name()))?;
157        } else {
158            fs::copy(&entry_path, dst.join(entry.file_name()))?;
159        }
160    }
161
162    Ok(())
163}