Skip to content

Latest commit

 

History

History
315 lines (235 loc) · 11.4 KB

File metadata and controls

315 lines (235 loc) · 11.4 KB

10 — CLI Basics (เรียน Rust แบบ command-driven)

TOC · Prev · Next

Keywords: CLI, error handling, modules, tooling

อ่านแบบคน Python:

  • ถ้าอยากเอา “ภาพรวม” ก่อน: จำ pattern parse → Command enum → execute
  • ถ้าอยาก “ลงมือทำ”: ทำให้ parse คืน Command ก่อน แล้วค่อยเติม behavior ทีละคำสั่ง
  • ถ้าติด: เปิด 12-learning-playbook.md

CLI เป็นสนามฝึก Rust ที่คุ้มมากสำหรับคนมาจาก Python เพราะมันบังคับให้คุณจัดระบบความคิดแบบ “รับ input ที่ไม่แน่นอน → แปลงเป็นข้อมูลที่ชัด → dispatch”

แก่นของบทนี้:

  • Args ในโลกจริง “ไม่ครบ/เกิน/พิมพ์ผิด” เป็นเรื่องปกติ
  • ใช้ enum + match เพื่อ encode “คำสั่งที่ถูกต้อง” ลงใน type
  • แยก parse ออกจาก execute เพื่อให้ test ได้ง่าย

1) std::env::args() แบบ minimal

Python (เทียบแนวคิด):

import sys
print("args=", sys.argv)

Output (example):

args= ['app.py', 'status']

Rust:

fn main() {
    let args: Vec<String> = std::env::args().collect();
    println!("args={args:?}");
}

Output (example):

args=["target/debug/app", "status"]

สิ่งที่ควรรู้ตั้งแต่แรก:

  • args[0] โดยทั่วไปคือ path ของโปรแกรม (ไม่ใช่ subcommand)
  • อย่าใช้ args[1] ตรง ๆ ถ้าคุณยังไม่ได้เช็กความยาว (จะ panic ได้)

1.1 ส่ง args ผ่าน cargo run

ถ้ารันผ่าน cargo run แล้วอยากส่ง args ให้โปรแกรมจริง ต้องคั่นด้วย --

  • cargo run -- status
  • cargo run -- set greeting hello

1.2 Pitfall: UTF-8 ของ args

  • std::env::args() ให้ String (UTF-8)
  • ถ้ามี arg ที่ไม่ใช่ UTF-8 อาจทำให้โปรแกรมพัง
  • ถ้าต้อง robust มาก ๆ ค่อยขยับไป std::env::args_os() (ได้ OsString) แล้วค่อยแปลง/validate เอง

สำหรับบทนี้ เราใช้ args() เพื่อโฟกัส “pattern การจัดโครง” ก่อน


2) Pattern หลัก: parse → command → execute

ให้คิดแบบสายงานจริง:

  1. รับ Vec<String> (raw input)
  2. parse เป็น Command (ชนิดข้อมูลที่ชัด)
  3. execute ตาม Command (logic ไม่ต้องมานั่งเช็ก args ซ้ำ)

ข้อดีเทียบกับ if/elif ยาว ๆ:

  • “คำสั่งที่ถูกต้อง” ถูกบังคับด้วย type
  • เพิ่มคำสั่งใหม่: เพิ่ม enum + เพิ่ม parse + เพิ่ม match
  • แยกส่วนได้: parse/test, execute/test

3) เวอร์ชันแรก: enum + match (พร้อม fallback แบบปลอดภัย)

enum Command {
    Help,
    Version,
    Status,
}

fn usage() -> &'static str {
    "usage: app [help|version|status]"
}

fn parse_command(args: &[String]) -> Command {
    let sub = args.get(1).map(|s| s.as_str());

    match sub {
        None | Some("help") => Command::Help,
        Some("version") => Command::Version,
        Some("status") => Command::Status,
        _ => Command::Help,
    }
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let cmd = parse_command(&args);

    match cmd {
        Command::Help => println!("{}", usage()),
        Command::Version => println!("v0"),
        Command::Status => println!("ok"),
    }
}

Output (example):

ok

ทำไม args.get(1) ถึงสำคัญ:

  • ผู้ใช้พิมพ์แค่ app (ไม่มี subcommand)
  • ผู้ใช้พิมพ์ app status extra (มีของเกิน)
  • ผู้ใช้พิมพ์ app stauts (typo)

ถ้าใช้ args[1] จะ panic ในเคสแรกทันที แต่ get(1) จะให้ Option แล้วคุณตัดสินใจ fallback ได้เอง


4) โตขึ้นอีกนิด: ให้ parse คืน Result (unknown command ไม่เงียบ)

เวอร์ชันก่อนอ่านง่าย แต่ “unknown command” ถูกกลืนเป็น help เงียบ ๆ ซึ่ง UX ไม่ดี (ผู้ใช้ไม่รู้ว่าพิมพ์ผิดตรงไหน)

แนวที่โตได้:

  • parse_command(&[String]) -> Result<Command, String>
  • ถ้า parse ไม่ได้: คืนข้อความ error ที่ชัด + พิมพ์ usage
enum Command {
    Help,
    Version,
    Status,
}

fn usage() -> &'static str {
    "usage: app [help|version|status]"
}

fn parse_command(args: &[String]) -> Result<Command, String> {
    let sub = args.get(1).map(|s| s.as_str());

    match sub {
        None | Some("help") | Some("--help") | Some("-h") => Ok(Command::Help),
        Some("version") => Ok(Command::Version),
        Some("status") => Ok(Command::Status),
        Some(other) => Err(format!("unknown command: {other}")),
    }
}

fn main() {
    let args: Vec<String> = std::env::args().collect();

    let cmd = match parse_command(&args) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("error: {e}");
            eprintln!("{}", usage());
            return;
        }
    };

    match cmd {
        Command::Help => println!("{}", usage()),
        Command::Version => println!("v0"),
        Command::Status => println!("ok"),
    }
}

ประโยชน์ที่ได้ทันที:

  • พิมพ์ผิดแล้ว “ดัง”
  • parse กับ execute แยกชัด ทำให้ unit test ง่าย

5) เพิ่ม subcommand ที่มี payload: set NAME VALUE

CLI จริงมักมีคำสั่งที่ต้องการข้อมูลเพิ่ม เช่น set ที่รับ NAME กับ VALUE

ให้ encode payload ลงใน enum:

enum Command {
    Help,
    Status,
    Set { name: String, value: String },
}

fn usage() -> &'static str {
    "usage: app [help|status|set NAME VALUE]"
}

fn parse_command(args: &[String]) -> Result<Command, String> {
    let sub = args.get(1).map(|s| s.as_str());

    match sub {
        None | Some("help") | Some("--help") | Some("-h") => Ok(Command::Help),
        Some("status") => Ok(Command::Status),
        Some("set") => {
            let name = args.get(2).ok_or_else(|| "missing NAME for 'set'".to_string())?;
            let value = args.get(3).ok_or_else(|| "missing VALUE for 'set'".to_string())?;

            Ok(Command::Set {
                name: name.clone(),
                value: value.clone(),
            })
        }
        Some(other) => Err(format!("unknown command: {other}")),
    }
}

fn main() {
    let args: Vec<String> = std::env::args().collect();

    let cmd = match parse_command(&args) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("error: {e}");
            eprintln!("{}", usage());
            return;
        }
    };

    match cmd {
        Command::Help => println!("{}", usage()),
        Command::Status => println!("ok"),
        Command::Set { name, value } => println!("set {name}={value}"),
    }
}

จุดสำคัญ:

  • ถ้าผ่าน parse_command แล้ว แปลว่า set มี name/value ครบ
  • ส่วน execute ไม่ต้องเช็ก args.len() ซ้ำอีก (logic สะอาดขึ้น)

5.1 Pitfall: tokenization ของ shell (VALUE มีช่องว่าง)

set NAME VALUE ถือว่า VALUE เป็น “หนึ่ง token”

ถ้าจะส่งข้อความที่มีช่องว่าง ผู้ใช้ต้อง quote ตาม shell:

  • app set greeting "hello world"

ถ้าคุณอยากรองรับ “รับค่าทั้งหมดที่เหลือเป็นข้อความเดียว” (rest-of-args):

  • เก็บ args[3..] แล้ว join(" ")

พอ CLI โตมาก ๆ ค่อยพิจารณาใช้ crate อย่าง clap เพื่อจัดการ quoting/flags/usage ให้ดีขึ้น


6) ทำให้ test ง่าย: unit test ที่ทดสอบเฉพาะ parse

เพราะเราออกแบบให้ parse รับ &[String] เราเขียน test ได้โดยไม่ต้องรันโปรแกรมจริง

#[test]
fn parse_status() {
    let args = vec!["app".to_string(), "status".to_string()];
    let cmd = parse_command(&args).unwrap();

    match cmd {
        Command::Status => {}
        _ => panic!("expected status"),
    }
}

#[test]
fn parse_set_missing_value() {
    let args = vec!["app".to_string(), "set".to_string(), "greeting".to_string()];
    let err = parse_command(&args).unwrap_err();
    assert!(err.contains("missing VALUE"));
}

Output (example):

(no output — tests pass silently)

หมายเหตุ: ตัวอย่างนี้ assume ว่าคุณอยู่ในไฟล์เดียวกันและ parse_command/Command visible กับ test ได้ (เช่นอยู่ใน main.rs เดียวกัน หรือย้าย logic ไป lib.rs ตามบท 05)


7) สรุป pattern ที่ใช้บ่อย

  • Pattern A: Vec<String>parse_command(&args)match cmd
  • Pattern B: Parse ให้เป็น enum ที่มี payload แล้วค่อย execute
  • Pattern C: Unknown/missing args ควร Err(...) + พิมพ์ usage
  • Pattern D: แยก parse/execute เพื่อ test ได้ และลดการ panic

8) แบบฝึกหัด

  1. เพิ่ม command version (พิมพ์ v0)
  2. เพิ่ม command set NAME VALUE แล้วให้ parse เป็น Command::Set { name, value }
  3. เพิ่ม command get NAME (เพื่อเตรียมไปบท mini project)
  4. ปรับ unknown command ให้พิมพ์ error: ... และตามด้วย usage: ...
  5. (ต่อยอด) ลองแยกไฟล์/โมดูล:
  • src/main.rs ทำแค่ parse args แล้วเรียก execute
  • src/lib.rs เก็บ Command, parse_command, และ logic ที่ test ได้