skip to content
Cover Image

Completing the MVP Version of LazyDraft

This is Part 4 in Building a CLI in Rust Series

In the last post, I implemented the config parsing portion of the CLI. In this part, I will tackle implementing the stage command to publish the CLI MVP version and fix a few bugs I’ve noticed early on.

By the way, I am writing this on Obsidian and am currently testing the look of the post on the local version of the website. So, writing this article will be a good session for finding new bugs and new improvement areas!

“Status” Update

I initially planned a list command to show me the current state of my writing. When I started implementing it, I noticed that the status could have been a better name choice for what I wanted to show the user.

When we run the status command, it shows this information:

A high-level version of the command is pretty predictable; it prints out the initial message, creates a writing list to show, and if that operation is successful, it prints the list

fn execute_status_command(config: &Config) -> std::io::Result<()> {
    println!("Here is the current status: ");
    match create_writing_list(config) {
        Ok(writings) => print_writing_list(writings),
        Err(_) => exit_with_message("Couldn't print the writing list!"),
    }
    Ok(())
}

To keep the relevant information, I created a Writing struct that holds all the necessary information. The path, title and is_draft are primarily used to show the information.

pub struct Writing {
    pub path: String,
    pub title: String,
    is_draft: bool,
    publish_date: Option<NaiveDate>,
}

publish_date is optional because I don’t always think of a date when I add an idea as a Writing to my vault. When I start to work on it, I add a temporary one to keep myself accountable.

Implementation of Stage Command

The core functionality of the tool is the stage command. There are certainly a couple of improvements we can make. But I am happy with this initial implementation. Let’s go over the things we do in this command:

  • We create a list to show to the user
  • We let users select from the draft writings
  • We then look for any asset related to this writing
    • If there are, we transfer them to the target directory.
  • We remove any undefined value from the frontmatter
  • We add a coverImage section to the frontmatter if we add a header image file.
  • We convert any image added with Wikilink format to standard Markdown format.

Command function looks quite imperative in terms of the tasks we need to accomplish:

fn execute_stage_command(config: &Config) -> std::io::Result<()> {
    let writing_list = create_writing_list(config).expect("Writing list could not be created");
    let selected_writing =
        select_draft_writing_from_list(&writing_list).expect("Writing is not selected");
    let asset_list = get_asset_list_of_writing(selected_writing, config)
        .expect("Asset List could not be created");

    match transfer_asset_files(config, asset_list) {
        Ok(_) => match update_writing_content_and_transfer(config, selected_writing) {
            Ok(_) => {
                println!("Writing transferred successfully.");
                Ok(())
            }
            Err(err) => Err(err),
        },
        Err(err) => Err(err),
    }
}

Selecting from drafts is done using the dialoguer crate. After choosing a draft and getting a related asset list of that writing, we call the update_writing_content_and_transfer function. This function formats both the front matter and the content of the writing. Frontmatter formatting options were not part of the initial implementation, but adding them was easy, so I implemented them with an option to disable them from the config.

if let Ok((frontmatter, markdown_content)) = read_markdown_file(&writing.path) {
	let mut modifiable_frontmatter = frontmatter.clone();
	if config.sanitize_frontmatter {
		remove_empty_values(&mut modifiable_frontmatter);
	}
	if config.auto_add_cover_img {
		add_cover_image(&mut modifiable_frontmatter, config, &asset_list);
	}
	// update markdown content	
}
        

For example, add_cover_image checks whether we have a header image in our asset list and if we have, it adds them to the frontMatter. the format for the header image is <assetPrefix>-header.

fn add_cover_image(frontmatter: &mut Value, config: &Config, asset_list: &Vec<Asset>) {
    let property_to_check = String::from(
        frontmatter["assetPrefix"]
            .as_str()
            .expect("assetPrefix should be defined"),
    ) + "-header";
    let matching_assets: Vec<&Asset> = asset_list
        .iter()
        .filter(|asset| asset.asset_path.contains(&property_to_check))
        .collect();
    if !matching_assets.is_empty() {
        let target_prefix = &config.target_asset_prefix;
        let header_name = Path::new(
            matching_assets
                .first()
                .expect("Header asset must exist")
                .asset_path
                .as_str(),
        )
        .file_name()
        .expect("Header asset name should be valid");
        let cover_img = CoverImage {
            src: Path::new(target_prefix)
                .join(header_name)
                .as_path()
                .display()
                .to_string(),
            alt: "Cover Image".to_string(),
        };
        frontmatter["coverImage"] =
            serde_yaml::to_value(&cover_img)
	            .expect("Cover Image format should match");
    }
}

In the last part, since we tackled the frontmatter and content separately, we need to merge them. At this point, I decided to create a result file at the target location. This helped to make the happy path of this command idempotent.

// merge and write the content
let writing_name = Path::new(&writing.path)
	.file_name()
	.expect("Could not parse writing name");
let target_file_name = Path::new(&config.target_dir).join(writing_name);
let merged_content = format!(
	"---\n{}\n{}",
	serde_yaml::to_string(&modifiable_frontmatter)
		.expect("frontmatter format should be correct after modification"),
	updated_content
);
let mut new_file = File::create(target_file_name)
	.expect("Could not create target file");

new_file.write_all(merged_content.as_bytes())

Conclusion

It feels nice to write using this tool. It removes any friction of transferring a writing to my website for local testing. It was also an excellent excuse to try Rust for the first time. Even though it has a steep learning curve, defining errors out of existence is much easier than the other languages I worked with.

In the future, I might refactor the overall implementation and add testing to the project. I also consider adding --continuous flag to monitor the writing and update the version in the target folder on the fly.


References: